Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
64 commits
Select commit Hold shift + click to select a range
3786cee
feat(noodles): add /noodles archive with per-noodle markdown pages
Adebesin-Cell May 20, 2026
1eb92ce
feat(noodles): case-study layout for individual noodle pages
Adebesin-Cell May 22, 2026
b8a9c76
fix(noodles): address CI failures
Adebesin-Cell May 22, 2026
df1bc25
fix(storybook): ignore noodle .md files in storybook build, mirrors b…
Adebesin-Cell May 22, 2026
1fd3721
refactor(noodles): drop detail pages, keep landing only
Adebesin-Cell May 22, 2026
1630205
refactor(noodles): drop detail pages, keep landing only
Adebesin-Cell May 22, 2026
1c4eb7f
feat(noodles): restore artemis, drop query-param trigger, fix bowl mo…
Adebesin-Cell May 22, 2026
fb70a25
feat(noodles): bring back optional detail page + cool 404
Adebesin-Cell May 22, 2026
7115cb3
feat(noodles): split kawaii into original + pride revision, center bo…
Adebesin-Cell May 22, 2026
350494f
feat(noodles): credits, load-more pagination, what-is restored, respo…
Adebesin-Cell May 22, 2026
2336298
fix(noodles): drop container wrapper, blog-style author credits
Adebesin-Cell May 22, 2026
f9a88c2
refactor(noodles): move credits into the metadata tombstone
Adebesin-Cell May 22, 2026
56dd591
fix(noodles): credits to own row in metadata, article padding on mobile
Adebesin-Cell May 22, 2026
7358124
feat(noodles): build-time Bluesky avatar resolution
Adebesin-Cell May 22, 2026
1ad1376
fix(noodles): address CI failures
Adebesin-Cell May 22, 2026
513bb9d
fix(i18n): regenerate schema after adding noodles keys
Adebesin-Cell May 22, 2026
0c10f8c
fix(noodles): throw on missing logo registration instead of silently …
Adebesin-Cell May 22, 2026
4e870d1
fix(noodles): timeout + Readable.fromWeb on bluesky avatar fetches
Adebesin-Cell May 22, 2026
b928860
docs(noodles): clarify in schema doc that occasion is optional
Adebesin-Cell May 22, 2026
d2c66ab
fix(noodles): use arrayBuffer for avatar writes to satisfy type check
Adebesin-Cell May 22, 2026
a397b84
feat(noodles): process carousel, mobile nav link, logo cleanup
Adebesin-Cell May 22, 2026
1b2cf68
fix(noodles): hide landing moon in light mode, restore artemis moon
Adebesin-Cell May 22, 2026
c9203a7
feat(noodles): magnifying-lens carousel on the detail page
Adebesin-Cell May 22, 2026
7f15eeb
feat(noodles): infinite-loop lens carousel + keyboard nav
Adebesin-Cell May 22, 2026
229ed32
a11y(noodles): reduced motion, dot focus ring, live region, 404 fix
Adebesin-Cell May 22, 2026
8efc84c
feat(noodles): per-noodle og images + bigger chevrons + comment trim
Adebesin-Cell May 22, 2026
c0f9839
fix(og): inline brand mark in Noodle.takumi to bypass auto-import issue
Adebesin-Cell May 22, 2026
0cd7e73
fix(og): use moon for artemis poster, not the wordmark
Adebesin-Cell May 22, 2026
3595a6e
fix(og): pin poster column to 360px so it stops bleeding off canvas
Adebesin-Cell May 22, 2026
641b831
fix(og): drop artemis backdrop, keep wordmark only
Adebesin-Cell May 22, 2026
0f169c0
feat(noodles): two-column detail layout + 404 shares same shell
Adebesin-Cell May 22, 2026
3e71ebc
fix(noodles): stretch detail hero on tall screens + raise 2-col break…
Adebesin-Cell May 22, 2026
6b9ca03
fix(noodles): vertically center detail content on tall screens
Adebesin-Cell May 22, 2026
f5798f9
fix(noodles): lens sticks to top of grid, drop vertical centering
Adebesin-Cell May 22, 2026
588c3a9
fix(noodles): sticky lens + drop dummy preview content
Adebesin-Cell May 22, 2026
9cba372
fix(noodles): drop orphaned Carousel.vue + noodles.process i18n key
Adebesin-Cell May 22, 2026
cdd7542
feat(noodles): infinite-loop dot indicator on the lens carousel
Adebesin-Cell May 24, 2026
98bec20
fix(noodles): use logical inset-is-0 on dot strip for RTL
Adebesin-Cell May 24, 2026
f17d649
refactor(noodles): use margin-inline-start to keep dot strip RTL-clean
Adebesin-Cell May 24, 2026
b9f441c
Merge remote-tracking branch 'upstream/main' into feat/noodles-archive
Adebesin-Cell May 26, 2026
408fc31
fix(noodles): rename kawaii-pride slug to transgender-visibility-day
Adebesin-Cell May 26, 2026
7bc61e0
refactor(noodles): rename kawaii-pride key + folder to transgender-vi…
Adebesin-Cell May 26, 2026
9f366f1
feat(noodles): per-noodle references row + credit updates + tooltip t…
Adebesin-Cell May 26, 2026
124792b
feat(noodles): surface in command palette + cleanup hero sticker
Adebesin-Cell May 26, 2026
2366532
refactor(noodles): single command-palette entry + soup icon
Adebesin-Cell May 26, 2026
4cbdd78
fix(noodles): split tooltip + bare branches on nodejs logo
Adebesin-Cell May 26, 2026
c31c68c
refactor(noodles): rename processImages to variants
Adebesin-Cell May 28, 2026
ae4e7b7
refactor(noodles): keep homepage permanent/active noodles as on main
Adebesin-Cell May 30, 2026
165a85b
Merge remote-tracking branch 'upstream/main' into feat/noodles-archive
Adebesin-Cell May 30, 2026
b95272b
chore(knip): drop ColorScheme/Img from ignoreFiles (now used by noodl…
Adebesin-Cell May 30, 2026
2be0c7c
fix(ui): address review feedback on noodles archive
Adebesin-Cell May 31, 2026
d50231a
refactor(ui): use remote Bluesky avatar URLs for noodles
Adebesin-Cell Jun 1, 2026
c9a4cf3
Merge remote-tracking branch 'upstream/main' into feat/noodles-archive
Adebesin-Cell Jun 1, 2026
6722f40
[autofix.ci] apply automated fixes
autofix-ci[bot] Jun 1, 2026
aa03dd1
fix: use color-scheme-img for noodles moon
alexdln Jun 3, 2026
f21e80f
fix: resolve errors for nodejs noodle og image
alexdln Jun 3, 2026
6652f1b
refactor: move noodles slider to a separate component
alexdln Jun 3, 2026
eaa105c
chore: minor code style improvements
alexdln Jun 3, 2026
3ba99f1
feat: enable prerender for noodles
alexdln Jun 3, 2026
cbd0831
refactor: simplify noodle credits avatars logic
alexdln Jun 3, 2026
0c766d0
chore: remove outdated row in gitignore
alexdln Jun 3, 2026
bcbefbd
test: add a11y test for noodle-lens
alexdln Jun 3, 2026
8bb083b
chore: fix duplicated classes
alexdln Jun 3, 2026
80fb372
Merge branch 'main' into feat/noodles-archive
alexdln Jun 3, 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
4 changes: 4 additions & 0 deletions app/components/AppFooter.vue
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,10 @@ const footerSections = computed<Array<{ label: string; links: FooterLink[] }>>((
name: t('pds.title'),
href: '/pds',
},
{
name: t('noodles.title'),
href: '/noodles',
},
{
name: t('footer.docs'),
href: NPMX_DOCS_SITE,
Expand Down
8 changes: 8 additions & 0 deletions app/components/AppHeader.vue
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,14 @@ const mobileLinks = computed<NavigationConfigWithGroups>(() => [
external: false,
iconClass: 'i-lucide:palette',
},
{
name: 'Noodles',
label: $t('noodles.title'),
to: { name: 'noodles' },
type: 'link',
external: false,
iconClass: 'i-lucide:sparkles',
},
],
},
{
Expand Down
19 changes: 19 additions & 0 deletions app/components/Noodle/Artemis/Logo.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<template>
<div class="relative w-80 sm:w-100 max-w-full">
<ColorSchemeImg
width="400"
class="motion-safe:animate-fade-in motion-safe:animate-scale-in relative z-10 w-full"
dark-src="/extra/npmx-dark-artemis.svg"
light-src="/extra/npmx-light-artemis.svg"
:alt="$t('alt_logo')"
/>
<ColorSchemeImg
width="1440"
height="455"
class="absolute -bottom-4 inset-is-0 w-full h-auto mix-blend-lighten light:mix-blend-darken motion-safe:animate-fade-in pointer-events-none select-none"
dark-src="/extra/moon-dark.png"
light-src="/extra/moon-light.png"
alt=""
/>
</div>
</template>
2 changes: 1 addition & 1 deletion app/components/Noodle/Kawaii/Logo.vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<template>
<img
width="400"
class="mb-8 motion-safe:animate-fade-in motion-safe:animate-scale-in motion-safe:hover:scale-105 motion-safe:transition w-80 sm:w-100"
class="motion-safe:animate-fade-in motion-safe:animate-scale-in w-80 sm:w-100"
src="/extra/npmx-cute.svg"
:alt="$t('alt_logo_kawaii')"
/>
Expand Down
256 changes: 256 additions & 0 deletions app/components/Noodle/Lens.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,256 @@
<script setup lang="ts">
import type { Component } from 'vue'
type LensSlide = { kind: 'logo'; logo: Component } | { kind: 'image'; src: string }
const props = defineProps<{
logo?: Component
variants?: string[]
title?: string
}>()
const variants = computed(() => props.variants ?? [])
const hasVariants = computed(() => variants.value.length > 0)
const lensSlides = computed<LensSlide[]>(() => {
if (!hasVariants.value) return []
const slides: LensSlide[] = []
if (props.logo) slides.push({ kind: 'logo', logo: props.logo })
for (const src of variants.value) {
slides.push({ kind: 'image', src })
}
return slides
})
const slideCount = computed(() => lensSlides.value.length)
const hasMultipleSlides = computed(() => slideCount.value > 1)
const prefersReducedMotion = useMediaQuery('(prefers-reduced-motion: reduce)')
const lensScroller = useTemplateRef<HTMLElement>('lensScroller')
const activeSlide = shallowRef(0)
// Position in the 3× slide list — keeps the snap-back loop in sync with `activeSlide`.
let rawIndex = 0
function snapBackIfNeeded() {
const el = lensScroller.value
if (!el) return
const width = el.clientWidth
const n = slideCount.value
if (width === 0 || n === 0) return
if (rawIndex < n) {
rawIndex += n
el.scrollLeft = rawIndex * width
} else if (rawIndex >= 2 * n) {
rawIndex -= n
el.scrollLeft = rawIndex * width
}
}
function onLensScroll() {
const el = lensScroller.value
if (!el) return
const width = el.clientWidth
const n = slideCount.value
if (width === 0 || n === 0) return
rawIndex = Math.round(el.scrollLeft / width)
activeSlide.value = ((rawIndex % n) + n) % n
}
function lensScrollTo(canonicalIndex: number) {
const el = lensScroller.value
if (!el) return
const n = slideCount.value
if (n === 0) return
// Shortest signed delta so wrapping across the loop seam still feels like one step.
let delta = (((canonicalIndex - activeSlide.value) % n) + n) % n
if (delta > n / 2) delta -= n
const target = el.children[rawIndex + delta] as HTMLElement | undefined
target?.scrollIntoView({
behavior: prefersReducedMotion.value ? 'auto' : 'smooth',
block: 'nearest',
inline: 'start',
})
}
function lensPrev() {
lensScrollTo((activeSlide.value - 1 + slideCount.value) % slideCount.value)
}
function lensNext() {
lensScrollTo((activeSlide.value + 1) % slideCount.value)
}
// Pagination dots: active dot always centered, neighbours fade out with distance.
// Distance is measured the short way around the cycle so the strip reads as an infinite reel.
const DOT_PITCH = 16
const DOT_FADE_RADIUS = 4
// margin-inline-start is logical, so the same formula centers the active dot
// in both LTR and RTL — no direction detection needed.
const dotStripStyle = computed(() => ({
marginInlineStart: `calc(50% - ${activeSlide.value * DOT_PITCH + DOT_PITCH / 2}px)`,
}))
function dotOpacity(index: number) {
const n = slideCount.value
if (n === 0) return 0
const linear = Math.abs(index - activeSlide.value)
const distance = Math.min(linear, n - linear)
return Math.max(0, 1 - distance / DOT_FADE_RADIUS)
}
// Dots that are fully faded are taken out of pointer + AT reach so they
// can't be clicked invisibly or trap focus.
function isDotHidden(index: number) {
return dotOpacity(index) === 0
}
function onLensKeydown(event: KeyboardEvent) {
if (!hasMultipleSlides.value) return
switch (event.key) {
case 'ArrowRight':
event.preventDefault()
lensNext()
break
case 'ArrowLeft':
event.preventDefault()
lensPrev()
break
case 'Home':
event.preventDefault()
lensScrollTo(0)
break
case 'End':
event.preventDefault()
lensScrollTo(slideCount.value - 1)
break
}
}
// Start in the middle copy so both edges can be reached before a snap-back.
onMounted(() => {
if (!hasMultipleSlides.value) return
const el = lensScroller.value
if (!el) return
const width = el.clientWidth
if (width === 0) return
rawIndex = slideCount.value
el.scrollLeft = rawIndex * width
activeSlide.value = 0
})
</script>

<template>
<div class="xl:sticky xl:top-24 flex flex-col items-center justify-self-center self-start">
<div class="relative aspect-square w-60 sm:w-80 lg:w-96 max-w-full">
<div
class="absolute inset-0 rounded-full overflow-hidden bg-bg-subtle border-[10px] sm:border-[14px] border-border [box-shadow:inset_0_0_50px_rgb(0_0_0/0.28),inset_0_2px_2px_rgb(255_255_255/0.9),0_20px_50px_-12px_rgb(0_0_0/0.3)] dark:[box-shadow:inset_0_0_60px_rgb(0_0_0/0.6),0_20px_50px_-10px_rgb(0_0_0/0.5)]"
>
<!-- 3× copies + snap-back is the loop trick. See onLensScroll/snapBackIfNeeded. -->
<div
v-if="hasVariants"
ref="lensScroller"
tabindex="0"
role="region"
aria-roledescription="carousel"
class="absolute inset-0 flex overflow-x-auto snap-x snap-mandatory [scrollbar-width:none] [&::-webkit-scrollbar]:hidden focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-inset"
:aria-label="$t('noodles.lens_label', { title: title ?? '' })"
@scroll.passive="onLensScroll"
@scrollend.passive="snapBackIfNeeded"
@keydown="onLensKeydown"
>
<template v-for="copy in 3" :key="`copy-${copy}`">
<div
v-for="(slide, index) in lensSlides"
:key="`${copy}-${index}`"
class="shrink-0 w-full h-full snap-start flex items-center justify-center p-6 sm:p-10"
:role="copy === 2 ? 'group' : undefined"
:aria-roledescription="copy === 2 ? 'slide' : undefined"
:aria-label="
copy === 2
? $t('noodles.lens_slide_position', {
index: index + 1,
total: slideCount,
})
: undefined
"
:aria-hidden="copy !== 2 ? 'true' : undefined"
>
<component
v-if="slide.kind === 'logo'"
:is="slide.logo"
no-tooltip
class="max-w-[80%] max-h-[80%]"
/>
<img
v-else
:src="slide.src"
:alt="
copy === 2 && title ? `${title} — ${$t('noodles.lens_slide', { index })}` : ''
"
loading="lazy"
class="max-w-[85%] max-h-[85%] object-contain"
/>
</div>
</template>
</div>
<div v-else class="absolute inset-0 flex items-center justify-center p-6 sm:p-10">
<component :is="logo" v-if="logo" no-tooltip class="max-w-[80%] max-h-[80%]" />
<span
v-else
class="font-mono text-6xl sm:text-8xl text-fg-subtle select-none"
aria-hidden="true"
>?</span
>
</div>
</div>

<template v-if="hasMultipleSlides">
<ButtonBase
type="button"
classicon="i-lucide:chevron-left"
class="hidden sm:inline-flex rtl-flip absolute top-1/2 -translate-y-1/2 -inset-is-16 lg:-inset-is-20 text-3xl !p-3 z-10"
:aria-label="$t('noodles.carousel_prev')"
@click="lensPrev"
/>
<ButtonBase
type="button"
classicon="i-lucide:chevron-right"
class="hidden sm:inline-flex rtl-flip absolute top-1/2 -translate-y-1/2 -inset-ie-16 lg:-inset-ie-20 text-3xl !p-3 z-10"
:aria-label="$t('noodles.carousel_next')"
@click="lensNext"
/>
</template>
</div>

<div
v-if="hasMultipleSlides"
class="mt-6 h-2 w-32 overflow-hidden flex items-center"
:aria-label="$t('noodles.carousel_dots')"
role="group"
>
<div
class="flex items-center gap-2 shrink-0 motion-safe:transition-[margin] motion-safe:duration-300 motion-safe:ease-out"
:style="dotStripStyle"
>
<button
v-for="(_, index) in lensSlides"
:key="index"
type="button"
class="block w-2 h-2 shrink-0 rounded-full bg-fg cursor-pointer motion-safe:transition-opacity focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-bg"
:class="isDotHidden(index) ? 'pointer-events-none' : ''"
:style="{ opacity: dotOpacity(index) }"
:aria-hidden="isDotHidden(index) ? 'true' : undefined"
:tabindex="isDotHidden(index) ? -1 : 0"
:aria-label="$t('noodles.carousel_jump', { index: index + 1 })"
:aria-current="index === activeSlide ? 'true' : undefined"
@click="lensScrollTo(index)"
/>
</div>
</div>

<div v-if="hasMultipleSlides" class="sr-only" aria-live="polite" aria-atomic="true">
{{ $t('noodles.lens_slide_position', { index: activeSlide + 1, total: slideCount }) }}
</div>
</div>
</template>
36 changes: 36 additions & 0 deletions app/components/Noodle/ListCard.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<script setup lang="ts">
import type { Noodle } from '#shared/schemas/noodle'
import { resolveNoodleLogo } from '../Noodle'

const props = defineProps<{
noodle: Noodle
}>()

const logo = computed(() => resolveNoodleLogo(props.noodle.key))
</script>

<template>
<NuxtLink
:to="`/noodles/${noodle.slug}`"
class="group relative block rounded-xl border border-border bg-bg-elevated overflow-hidden transition-colors hover:border-border-hover decoration-none"
>
<span class="sr-only">{{ noodle.title }}</span>
<div class="aspect-[4/3] flex items-center justify-center bg-bg p-8 overflow-hidden">
<component :is="logo" v-if="logo" no-tooltip class="max-w-full! max-h-full!" />
<span v-else class="i-lucide:sparkles w-12 h-12 text-fg-subtle" aria-hidden="true" />
</div>
<div class="absolute top-3 inset-ie-3 text-fg-subtle group-hover:text-fg transition-colors">
<span class="i-lucide:arrow-up-right rtl-flip w-4 h-4" aria-hidden="true" />
</div>
<div
class="border-t border-border px-4 py-3 flex items-center gap-2 text-xs text-fg-muted font-mono"
>
<span class="text-fg-subtle">//</span>
<DateTime :datetime="noodle.date" year="numeric" month="2-digit" day="2-digit" />
<template v-if="noodle.dateTo">
<span class="text-fg-subtle">—</span>
<DateTime :datetime="noodle.dateTo" year="numeric" month="2-digit" day="2-digit" />
</template>
</div>
</NuxtLink>
</template>
Loading
Loading