From 0c59db59d2fe5c98080e4f0c38f12be5221b48cb Mon Sep 17 00:00:00 2001 From: Nathael Bonnal Date: Sun, 26 Jul 2026 19:20:10 +0200 Subject: [PATCH 1/3] polish hero, comparison table, and OG thumbnail design --- .../src/components/comparison-section.astro | 7 +- .../src/components/core-team-section.astro | 2 +- apps/website/src/components/cta-section.astro | 2 +- .../website/src/components/hero-section.astro | 16 +- .../components/latest-articles-section.astro | 2 +- .../src/components/why-now-section.astro | 4 + packages/thumbnail/src/generator.tsx | 179 ++++++++++++++---- 7 files changed, 163 insertions(+), 49 deletions(-) diff --git a/apps/website/src/components/comparison-section.astro b/apps/website/src/components/comparison-section.astro index 79547f4..130d792 100644 --- a/apps/website/src/components/comparison-section.astro +++ b/apps/website/src/components/comparison-section.astro @@ -105,7 +105,12 @@ const rows: Row[] = [
- +
+ + + + +
diff --git a/apps/website/src/components/core-team-section.astro b/apps/website/src/components/core-team-section.astro index 6494097..f294d30 100644 --- a/apps/website/src/components/core-team-section.astro +++ b/apps/website/src/components/core-team-section.astro @@ -51,7 +51,7 @@ const team = [ -
+
{team.map((member, i) => ( -

+

A Modern IAM Built for Distributed Systems @@ -70,7 +70,7 @@ import { Button } from "@explainer/ui"; rel="noopener noreferrer" > Getting Started -

-
+
FerrisKey
diff --git a/apps/website/src/components/latest-articles-section.astro b/apps/website/src/components/latest-articles-section.astro index cd892df..1791d28 100644 --- a/apps/website/src/components/latest-articles-section.astro +++ b/apps/website/src/components/latest-articles-section.astro @@ -161,7 +161,7 @@ function formatDate(date: Date, locale: string) { class="inline-flex items-center gap-2 text-sm font-medium text-primary hover:underline transition-colors" > View all articles - +
diff --git a/apps/website/src/components/why-now-section.astro b/apps/website/src/components/why-now-section.astro index b03bbb0..9442382 100644 --- a/apps/website/src/components/why-now-section.astro +++ b/apps/website/src/components/why-now-section.astro @@ -415,6 +415,10 @@ const ARROW_FILL = 'rgba(255,255,255,0.55)' Users + {/* Occluder: hides the traveling dot behind the hub — the hub's own fill is + a near-transparent glow, so without this the dot would show straight through it */} + + {/* FerrisKey hub — one solid block, presented as ONE thing, breathing gently */} diff --git a/packages/thumbnail/src/generator.tsx b/packages/thumbnail/src/generator.tsx index 5958932..22fb001 100644 --- a/packages/thumbnail/src/generator.tsx +++ b/packages/thumbnail/src/generator.tsx @@ -17,57 +17,159 @@ export interface ThumbnailOptions { primaryColor?: string } +// Satori's line-clamp support isn't reliable here (text kept overflowing past the +// intended line count), so length is capped in plain JS instead — deterministic +// regardless of font metrics, and keeps the backing panel sizing predictable. +function truncate(text: string, maxLength: number): string { + if (text.length <= maxLength) return text + const cut = text.slice(0, maxLength) + const lastSpace = cut.lastIndexOf(' ') + return `${cut.slice(0, lastSpace > 0 ? lastSpace : maxLength)}…` +} + +// Light theme, FerrisKey colors — a warm off-white base (same family as the +// feature panels' paper tone) with a crisp white spotlight card on top for contrast. +const BG_COLOR = '#fdf6ec' +const WIDTH = 960 +const HEIGHT = 540 +const SPOTLIGHT_BG = '#ffffff' +const PIXEL_DARK = '#1c1409' +const PIXEL_LIGHT = '#fde68a' +const TITLE_COLOR = '#1c1409' +const DESCRIPTION_COLOR = '#57534e' + +// Uniform mosaic: every drawn tile is the exact same, larger square — no merged/ +// bigger squares, just fewer, bigger tiles with generous gaps between them. +const CELL = 120 +const COLS = Math.ceil(WIDTH / CELL) // 8 +const ROWS = Math.ceil(HEIGHT / CELL) // 5 (last row runs slightly past the canvas, clipped by the svg viewport) + +// The spotlight badge takes over this exact grid cell instead of floating at its +// own offset — same size, same position as any other tile in the mosaic. +const SPOTLIGHT_COL = 6 +const SPOTLIGHT_ROW = 1 + +interface MosaicTile { + x: number + y: number + dark: boolean +} + +function buildMosaic(): MosaicTile[] { + const tiles: MosaicTile[] = [] + for (let r = 0; r < ROWS; r++) { + for (let c = 0; c < COLS; c++) { + if (r === SPOTLIGHT_ROW && c === SPOTLIGHT_COL) continue // reserved for the spotlight badge + const hash = (c * 13 + r * 7 + (c % 5) * (r % 3)) % 9 + if (hash < 5) continue // gap: base background shows through — more gaps than tiles + tiles.push({ x: c * CELL, y: r * CELL, dark: hash < 7 }) + } + } + return tiles +} +const MOSAIC = buildMosaic() + +// An 8x14 "pixel art" key, drawn as a bitmap — each character is one cell, +// rendered as its own , the same way a retro app-icon sprite is built. +const KEY_SPRITE = [ + '..KKKK..', + '.KOOOOK.', + 'KOOHHOOK', + 'KOH..HOK', + 'KOOHHOOK', + '.KOOOOK.', + '..KKKK..', + '...KK...', + '...KK...', + '...KKKK.', + '...KK.K.', + '...KKKK.', + '...KK.K.', + '...KKKK.', +] +const PIXEL_CELL = 8 + +function pixelIcon(primaryColor: string) { + const rects: { x: number; y: number; fill: string }[] = [] + KEY_SPRITE.forEach((row, ry) => { + row.split('').forEach((ch, rx) => { + if (ch === '.') return + const fill = ch === 'K' ? PIXEL_DARK : ch === 'H' ? PIXEL_LIGHT : primaryColor + rects.push({ x: rx * PIXEL_CELL, y: ry * PIXEL_CELL, fill }) + }) + }) + return rects +} + export async function generateThumbnail(options: ThumbnailOptions): Promise { - const { headline, title, description } = options + const { headline } = options + const title = truncate(options.title, 50) + const description = options.description ? truncate(options.description, 130) : undefined const primaryColor = resolveColor(options.primaryColor) + const spotlightSize = CELL - 2 + const spotlightX = SPOTLIGHT_COL * CELL + 1 + const spotlightY = SPOTLIGHT_ROW * CELL + 1 + const iconW = 8 * PIXEL_CELL + const iconH = 14 * PIXEL_CELL return satori( -
- - - + {/* Mosaic: square tiles only (60px or 120px), two orange tones, gaps showing the + cream base through — the same texture language as the feature panels */} + + {MOSAIC.map((t) => ( + - - - - - - - - + ))} + + {/* Text backing panel: guarantees contrast for the title/description regardless of + which mosaic tile happens to fall behind them, instead of relying on luck */} + + + {/* Small accents in the untouched left margin, echoing the reference cards' scattered glyphs */} + + -
+ {/* Spotlight badge: a light card with a pixel-art key, in the clear space the text never reaches */} +
+ + {pixelIcon(primaryColor).map((p) => ( + + ))} + +
+ +
{headline && (

{headline}

)}

@@ -75,12 +177,15 @@ export async function generateThumbnail(options: ThumbnailOptions): Promise {description && (

{description} From c4f4b05939fc04d1e12550ef6114667084dda42a Mon Sep 17 00:00:00 2001 From: Nathael Bonnal Date: Sun, 26 Jul 2026 21:14:28 +0200 Subject: [PATCH 2/3] improve mobile layout for comparison tabs, hero diagram, and feature panels --- .../src/components/comparison-section.astro | 28 +++++----- .../src/components/features-section.astro | 32 ++++++----- .../website/src/components/hero-section.astro | 54 +++++++++---------- 3 files changed, 62 insertions(+), 52 deletions(-) diff --git a/apps/website/src/components/comparison-section.astro b/apps/website/src/components/comparison-section.astro index 130d792..f96da84 100644 --- a/apps/website/src/components/comparison-section.astro +++ b/apps/website/src/components/comparison-section.astro @@ -87,19 +87,21 @@ const rows: Row[] = [ -

- Compare vs - {competitors.map((c, i) => ( - - ))} +
+ Compare vs +
+ {competitors.map((c, i) => ( + + ))} +
diff --git a/apps/website/src/components/features-section.astro b/apps/website/src/components/features-section.astro index e308ee0..8dd4ee4 100644 --- a/apps/website/src/components/features-section.astro +++ b/apps/website/src/components/features-section.astro @@ -234,6 +234,25 @@ const ICON_PATHS: Record = { style={`background-color:${p.bg};`} > {(() => { const diagram = DIAGRAMS[card.key]; return ( + <> + {/* Background layer: "slice" so the mosaic always fills the box edge-to-edge, + regardless of the panel's aspect ratio at each breakpoint — a gap here would + read as a bug, unlike the foreground diagram which must stay uncropped. */} + + + {/* Foreground layer: "meet" so the boxes/connectors are never cropped */} + )})()}
diff --git a/apps/website/src/components/hero-section.astro b/apps/website/src/components/hero-section.astro index 1658d17..0cb8de4 100644 --- a/apps/website/src/components/hero-section.astro +++ b/apps/website/src/components/hero-section.astro @@ -105,65 +105,65 @@ import { Button } from "@explainer/ui";
-
+
-
- +
+ - IdP + IdP
-
- +
+ - SSO + SSO
-
- +
+ - Organizations + Organizations
-
- +
+ - MFA + MFA
-
- FerrisKey +
+ FerrisKey
-
- +
+ - Branding + Branding
-
- +
+ - RBAC + RBAC
-
- +
+ - Audits + Audits
-
- +
+ - Webhooks + Webhooks
From c701761489d6135aa170fda7e1e7d5c7fcb55206 Mon Sep 17 00:00:00 2001 From: Nathael Bonnal Date: Sun, 26 Jul 2026 21:50:48 +0200 Subject: [PATCH 3/3] redesign blog listing page with Mistral-inspired layout --- apps/blog/src/components/PostCard.astro | 126 +++++++----------------- apps/blog/src/components/TagFilter.tsx | 65 ++++++------ apps/blog/src/i18n/ui.ts | 14 ++- apps/blog/src/pages/index.astro | 74 ++++++++++---- 4 files changed, 134 insertions(+), 145 deletions(-) diff --git a/apps/blog/src/components/PostCard.astro b/apps/blog/src/components/PostCard.astro index ee1a6bb..090d7e9 100644 --- a/apps/blog/src/components/PostCard.astro +++ b/apps/blog/src/components/PostCard.astro @@ -12,102 +12,50 @@ interface Props { locale?: string readingTime: number author?: Author + featured?: boolean class?: string } -const { title, description, date, tags, cover, href, locale, readingTime, author, class: className } = Astro.props +const { title, description, date, tags, cover, href, locale, author, featured = false, class: className } = Astro.props +const category = tags[0] --- -
- -
-
- -
- {cover ? ( - {title} - ) : ( -
- )} -
- -
-
- {tags.slice(0, 3).map((tag) => ( - - {tag} - - ))} - + - - - - diff --git a/apps/blog/src/components/TagFilter.tsx b/apps/blog/src/components/TagFilter.tsx index 386aa2b..2b540d1 100644 --- a/apps/blog/src/components/TagFilter.tsx +++ b/apps/blog/src/components/TagFilter.tsx @@ -3,19 +3,11 @@ import { useTranslations } from '../i18n/utils'; interface TagFilterProps { tags: { name: string; count: number }[] + totalCount: number initialTags?: string[] locale?: string } -function TagIcon({ className }: { className?: string }) { - return ( - - - - - ) -} - function SearchIcon({ className }: { className?: string }) { return ( @@ -41,7 +33,7 @@ function writeURL(selectedTags: string[], query: string) { window.history.replaceState(null, '', url) } -export function TagFilter({ tags, initialTags = [], locale = 'en' }: TagFilterProps) { +export function TagFilter({ tags, totalCount, initialTags = [], locale = 'en' }: TagFilterProps) { const t = useTranslations(locale) const [selectedTags, setSelectedTags] = useState(initialTags) const [query, setQuery] = useState('') @@ -88,38 +80,43 @@ export function TagFilter({ tags, initialTags = [], locale = 'en' }: TagFilterPr return ( <> -
-
+
+
+ {t('index.filterLabel')} + {totalCount} {t('index.articleCount')} +
+ {tags.map((tag) => { + const active = selectedTags.includes(tag.name) + return ( + + ) + })} +
+
+ +
setQuery(e.target.value)} placeholder={t('tagFilter.placeholder')} - className="w-full rounded-md border border-border bg-transparent pl-10 pr-4 py-2 max-w-sm text-sm text-foreground placeholder:text-muted-foreground outline-none transition-colors focus:border-primary/50" + className="w-full lg:w-64 rounded-md border border-border bg-transparent pl-10 pr-4 py-2 text-sm text-foreground placeholder:text-muted-foreground outline-none transition-colors focus:border-primary/50" />
- -
- {tags.map((tag) => { - const active = selectedTags.includes(tag.name) - return ( - - ) - })} -
{hasActiveFilter && visibleCount === 0 && ( diff --git a/apps/blog/src/i18n/ui.ts b/apps/blog/src/i18n/ui.ts index 6b95da4..b89b743 100644 --- a/apps/blog/src/i18n/ui.ts +++ b/apps/blog/src/i18n/ui.ts @@ -4,10 +4,13 @@ export const ui = { en: { // Index page 'index.title': 'Articles', - 'index.heading.prefix': 'Latest', - 'index.heading.highlight': 'articles', + 'index.label': 'Blog', + 'index.heading.prefix': 'Latest updates from', + 'index.heading.highlight': 'FerrisKey.', 'index.empty': 'No articles yet.', 'index.noResults': 'No articles match your search.', + 'index.filterLabel': 'Filter by category', + 'index.articleCount': 'articles', // RSS 'rss.title': 'Explainer Blog', @@ -49,10 +52,13 @@ export const ui = { fr: { // Index page 'index.title': 'Articles', - 'index.heading.prefix': 'Derniers', - 'index.heading.highlight': 'articles', + 'index.label': 'Blog', + 'index.heading.prefix': 'Les dernières nouvelles de', + 'index.heading.highlight': 'FerrisKey.', 'index.empty': 'Aucun article pour le moment.', 'index.noResults': 'Aucun article ne correspond à votre recherche.', + 'index.filterLabel': 'Filtrer par catégorie', + 'index.articleCount': 'articles', // RSS 'rss.title': 'Blog Explainer', diff --git a/apps/blog/src/pages/index.astro b/apps/blog/src/pages/index.astro index c74ff6d..e4dfcf6 100644 --- a/apps/blog/src/pages/index.astro +++ b/apps/blog/src/pages/index.astro @@ -3,7 +3,7 @@ import { getCollection } from 'astro:content' import Base from '../layouts/base.astro' import { TagFilter } from '../components/TagFilter' import PostCard from '../components/PostCard.astro' -import { getPublishedPosts, getPostHref, getPostLocale, getLocales, getAllTags, getReadingTime } from '../lib/posts' +import { getPublishedPosts, getPostHref, getPostLocale, getPostsByLocale, getLocales, getAllTags, getReadingTime, getFeaturedPosts, getNonFeaturedPosts } from '../lib/posts' import { getAuthor } from '../lib/authors' import { defaultLang } from '../i18n/ui' import { useTranslations } from '../i18n/utils' @@ -14,35 +14,73 @@ const posts = getPublishedPosts(allPosts) const tags = getAllTags(posts) const locale = defaultLang const t = useTranslations(locale) + +// Split into featured/rest per locale, not across the mixed-locale array — +// otherwise an EN and FR copy of the same post can both land in the 2 +// featured slots, and hiding the wrong-locale one leaves an empty grid gap. +const postsByLocale = locales.map((loc) => { + const localePosts = getPostsByLocale(posts, loc) + return { + loc, + featured: getFeaturedPosts(localePosts, 2), + rest: getNonFeaturedPosts(localePosts, 2), + } +}) + +function cardProps(post: (typeof posts)[number]) { + return { + title: post.data.title, + description: post.data.short_description ?? post.data.description, + date: post.data.date, + tags: post.data.tags, + cover: post.data.cover ?? `${getPostHref(post)}/thumbnail.png`, + href: getPostHref(post), + locale, + readingTime: getReadingTime(post.body ?? ''), + author: post.data.author ? getAuthor(post.data.author) : undefined, + } +} --- -
-

+
+

+ + {t('index.label')} +

+

{t('index.heading.prefix')} {t('index.heading.highlight')}

- + {posts.length > 0 && ( -
- {posts.map((post) => ( -
- +
+ {postsByLocale.map(({ loc, featured, rest }) => ( +
+ {featured.length > 0 && ( +
1 && 'lg:grid-cols-2']}> + {featured.map((post) => ( +
+ +
+ ))} +
+ )} + + {rest.length > 0 && ( +
+ {rest.map((post) => ( +
+ +
+ ))} +
+ )}
))}