diff --git a/.claude/skills/lowpoly-image/SKILL.md b/.claude/skills/lowpoly-image/SKILL.md new file mode 100644 index 0000000..b9d086a --- /dev/null +++ b/.claude/skills/lowpoly-image/SKILL.md @@ -0,0 +1,145 @@ +--- +name: lowpoly-image +description: "Crée, corrige ou décline une image de la banque low-poly de kbrdn.dev (la mascotte dans un monde 3D low-poly, public/images/lowpoly) avec codex et sa génération d'images, puis la branche dans le site (notch, « À découvrir », images OG). Processus et revue de la série d'origine (#127), à l'identique. Déclencheurs : nouvelle image low-poly, nouvelle scène de la mascotte, variante claire, corriger une image de la banque, image OG d'une page, recréer une image comme celles de lowpoly." +--- + +# Images low-poly de la mascotte + +**La vérité est `docs/LOWPOLY.md`** : le brief de style (verbatim dans chaque prompt), le +tableau des images et de leurs prompts, les prompts d'édition, où chaque image sert. Le lire +d'abord. Cette skill porte le **processus** et ses pièges ; elle ne recopie pas le brief. + +Travailler dans un worktree (`/me:issue-worktree-pr`), jamais dans le checkout principal où +peut tourner `bun run dev`. + +## 0. Pré-vol + +```bash +codex --version && codex features list | grep -E '^(image_generation|view_image) ' # stable, true +df -h / | tail -1 # chaque image ≈ 1,5 Mo, plus sa copie dans ~/.codex/generated_images +``` + +⛔ Disque plein = codex meurt en route (`No space left on device`) après avoir parfois copié +une image à moitié vérifiée. Vérifier l'espace avant un lot, pas après. + +Codex se lance en `codex exec` direct. `sessions.start` de MonoCode n'est ouvert qu'aux +threads Operator (« Operator is not enabled for this thread »). + +## 1. Les références + +Dans un dossier de travail hors du repo (`/tmp/lowpoly-refs`), jamais commité : + +```bash +R=/tmp/lowpoly-refs; L=public/images/lowpoly; mkdir -p $R +magick $L/mascot-sheet.png -crop 780x887+994+0 +repage $R/sheet-head.png +magick -background none -density 300 public/images/logo/k-light.svg -resize 512x512 $R/k-light.png +# la planche elle-même seulement : les portraits 2D +for f in full-body hoodie flag-code coffee; do magick public/images/mascot/$f.webp $R/$f.png; done +``` + +`k-light` (barre claire, deux pièces orange), jamais `k-orange` : la variante tout orange fait +dessiner une simple lettre K. + +## 2. Générer + +1. Ajouter d'abord la ligne de l'image au tableau de `docs/LOWPOLY.md` (fichier, format, prompt). + Pose **debout ou assise** : accroupie, la mascotte sort avec une basket de trop ou sans jambe. +2. Un fichier de prompt par image, puis une session codex par image (en parallèle, c'est sûr) : + +```bash +WT=$(git rev-parse --show-toplevel); L=$WT/public/images/lowpoly; R=/tmp/lowpoly-refs; D=/tmp/lowpoly-run +nohup codex exec -s workspace-write -C "$WT" --json -o $D/-last.md \ + -i $L/mascot-sheet.png -i $R/sheet-head.png -i $R/k-light.png -i $L/scene-coding.png \ + - < $D/.txt > $D/.jsonl 2> $D/.err & +``` + +Le prompt dit, dans l'ordre : +- lire `docs/LOWPOLY.md` ; +- ce qu'est chaque pièce jointe : la planche, le gros plan de tête, le logo, une scène validée pour le monde et la lumière ; +- générer **une** image : le brief verbatim, puis sa ligne du tableau, en 1536×1024 paysage ; +- la copier depuis le dossier `generated_images` de **sa** session vers `public/images/lowpoly/` ; +- garder la sortie native : jamais de pad, de recadrage ni de rééchantillonnage (`sips`) ; +- ne toucher à rien d'autre, ne pas commiter, et finir par la source, la destination et la sortie de `file`. + +Chaque image tombe dans `~/.codex/generated_images//` : le `thread_id` est la première +ligne du `.jsonl`. Ne jamais prendre « le dernier PNG » par date : plusieurs sessions écrivent +en même temps. + +Attendre la fin sans que la boucle se trouve elle-même : + +```bash +until ! pgrep -f "codex exec.*lowpoly-ru[n]/" >/dev/null; do sleep 15; done +``` + +(`pgrep -f "lowpoly-run/..."` se trouve dans la ligne de commande de la boucle et ne rend +jamais la main ; la classe `[n]` l'en empêche.) + +## 3. La revue, le seul gate + +Ouvrir chaque PNG. D'abord la scène entière, puis **la mascotte recadrée en gros plan** : à +l'échelle de la scène, une basket en trop ou un visage de travers ne se voient pas. + +```bash +magick $L/ -crop x++ +repage -resize x500 /tmp/lowpoly-run/zoom.png +``` + +Contre la planche, un par un : + +- [ ] tête de la planche : masque crème cerclé d'une arcade orange, yeux blancs à pupille noire, l'air assuré ; pas un visage rond aux gros iris ; +- [ ] casquette noire à l'envers et sa patte orange (sauf costume voulu, comme le casque de `vellum`) ; +- [ ] logo `k-light` petit, sur la poitrine, **de face seulement** : jamais dans le dos, jamais une lettre K ; +- [ ] mains **beige** (la crème du masque), avant-bras en fourrure orange ; +- [ ] hoodie noir à **manches courtes** ; +- [ ] **pas de queue** ; +- [ ] deux jambes, **deux baskets**, chacune au bout d'une jambe ; +- [ ] aucun texte, chiffre ou glyphe (écrans et papiers : des barres), aucun K hors du hoodie ; +- [ ] aucun logo, produit ou personnage tiers, aucun autre personnage, figurines comprises ; +- [ ] palette du brief, rien de saturé hors des petits accents. + +## 4. Corriger + +En **édition**, pas en régénérant : l'image fautive en premier `-i`, puis les références utiles, +et le prompt d'édition de `docs/LOWPOLY.md`, qui ne nomme que ce qui change. Une session par +image, revue avant la suite, puis une grille d'ensemble pour vérifier que les décors n'ont pas +bougé. Deux éditions sans effet sur une anatomie : changer la pose plutôt qu'insister. + +## 5. Optimiser + +```bash +oxipng -o 2 --strip safe public/images/lowpoly/ +magick identify -format '%[type]\n' public/images/lowpoly/*.png | sort | uniq -c # TrueColor seulement +``` + +Pas de PNG en palette ni de quantification : ipx les regonfle. + +## 6. Brancher + +Les règles d'intégration sont dans `docs/LOWPOLY.md`, section « Où elles servent ». En bref : +- jamais un PNG brut dans une page : `$img(src, { width, format: 'webp' })`, puis `UiPixelImage` ; +- une variante `-light` seulement là où le site change d'image avec le mode ; +- une OG : le recadrage `og/`, sa clé dans la liste blanche `SCENES` de `OgImage/Default.satori.vue`, la clé `scene` de la page. + +Puis les gates. ⛔ Arrêter tout `bun run dev` du worktree avant le build : il vide la sqlite +content. + +```bash +nix develop -c bun run lint && nix develop -c bun run fmt:check +nix develop -c bunx vue-tsc -b --noEmit +nix develop -c bun run build && nix develop -c ./scripts/og-smoke.sh --update # si une OG change +git status --short scripts/og-smoke/ # seules les OG voulues ; /blog/gwm doit rester à l'octet +``` + +Vérifier dans un vrai navigateur (dev sur un port à part, `--port 3010`) : le notch au +survol, « À découvrir » en clair, sombre et verre, et une URL `/_ipx/...lowpoly...` en 200 +de quelques dizaines de Ko. + +## 7. Commiter + +Le merge vers `dev` se fait en merge commit : chaque version intermédiaire d'un PNG resterait +pour toujours dans l'historique. Avant de pousser, **replier** les révisions d'images dans un +seul commit d'images (`git reset --soft origin/dev`, puis recommiter par sujet, puis +`git push --force-with-lease` sur la branche de feature). Sujets : images, intégration (un +commit par endroit), OG avec ses références, doc, changelog. + +Une image visible sur le site a son entrée sous `[Unreleased]` ; une image seulement ajoutée à +la banque n'en a pas. diff --git a/.gitignore b/.gitignore index ff9ff0d..a9fd24e 100644 --- a/.gitignore +++ b/.gitignore @@ -35,11 +35,13 @@ lerna-debug.log* .vscode/* !.vscode/extensions.json -# Claude Code local state, sauf les commandes du repo (/changelog fait partie -# du protocole de release, cf. docs/RELEASE.md). La négation exige d'exclure le -# contenu (.claude/*) et non le dossier : git n'entre pas dans un dossier exclu. +# Claude Code local state, sauf les commandes et les skills du repo (/changelog +# fait partie du protocole de release, cf. docs/RELEASE.md ; lowpoly-image celui +# des images de docs/LOWPOLY.md). La négation exige d'exclure le contenu +# (.claude/*) et non le dossier : git n'entre pas dans un dossier exclu. .claude/* !.claude/commands/ +!.claude/skills/ # CI/CD artifacts hors repo *.tmp diff --git a/CHANGELOG.md b/CHANGELOG.md index b267c93..39e899c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ migrates `[Unreleased]` into that file and empties this one, see ## Past releases +- [2.1.0](changelogs/2.1.0.md) · 2026-11-02 - [2.0.0](changelogs/2.0.0.md) · 2026-10-29 - [1.1.0](changelogs/1.1.0.md) · 2026-10-16 - [1.0.2](changelogs/1.0.2.md) · 2026-09-19 diff --git a/app/components/OgImage/Default.satori.vue b/app/components/OgImage/Default.satori.vue index 839b75b..48d96fe 100644 --- a/app/components/OgImage/Default.satori.vue +++ b/app/components/OgImage/Default.satori.vue @@ -2,20 +2,10 @@ // The pages' OG image, the repos' banners' card (disarto's "Deployment" sheets): the dither // field, two dashed construction lines along the panel's sides, nothing else outside it. The // panel: a header (the handle, the window's icons), the card (the name and the title in Fenix, -// the buttons; the drawn portrait, or the page's pixel scene, and the facts; the career in +// the buttons; the drawn portrait, or the page's low-poly scene, and the facts; the career in // figures on the home, the stack elsewhere), a footer (icons, the address in mono, a button). // Satori: every style on one line (its gradient parser fails on split values), every box // with several children a flex box. -import { HEROES } from '~/utils/heroes' -import { - PIXEL_SCENES, - SCENE_H, - SCENE_OG_TICK, - SCENE_W, - sceneColor, - type PixelSceneName, -} from '~/utils/pixelScenes' - const props = withDefaults(defineProps<{ title?: string subtitle?: string @@ -31,8 +21,8 @@ const props = withDefaults(defineProps<{ /** The page's address, and its language (useSiteOgImage) */ address?: string lang?: string - /** The page's pixel scene (utils/pixelScenes) in place of the portrait */ - scene?: PixelSceneName + /** The page's low-poly scene of the mascot (public/images/lowpoly/og) in place of the portrait */ + scene?: string }>(), { title: 'Kylian Bardini', subtitle: 'Full Stack Developer', @@ -50,35 +40,12 @@ const props = withDefaults(defineProps<{ scene: undefined, }) -// The scene at its best moment, in the dark theme's colours (no CSS in satori): the page's -// hero (utils/heroes) at its resting frame, each row's runs of a colour as one rect; else the -// flat scene it replaced -const sceneBox = computed(() => { - if (!props.scene) return undefined - // An unknown name (the OG URL is open to anyone) draws the portrait instead; own keys - // only, or `constructor` and the like pass for a scene - if (!Object.hasOwn(PIXEL_SCENES, props.scene)) return undefined - const hero = HEROES[props.scene] - if (hero) { - const art = hero.still(true).copy().over(hero.paint(hero.rest, true)) - const cells: { x: number, y: number, w: number, h: number, fill: string, opacity: number }[] = [] - for (let y = 0; y < art.h; y++) - for (let x = 0; x < art.w;) { - const fill = art.cells[y * art.w + x] - let w = 1 - while (x + w < art.w && art.cells[y * art.w + x + w] === fill) w++ - if (fill) cells.push({ x, y, w, h: 1, fill, opacity: 1 }) - x += w - } - return { w: art.w, h: art.h, cells } - } - const scene = PIXEL_SCENES[props.scene] - const cells = [...scene.still, ...scene.frame(SCENE_OG_TICK[props.scene])].flatMap((cell) => { - const color = sceneColor(cell.c) - return color ? [{ ...cell, fill: color[0], opacity: color[1] }] : [] - }) - return { w: SCENE_W, h: SCENE_H, cells } -}) +// The scenes cropped to the card's box (docs/LOWPOLY.md). An unknown name (the OG URL is open to +// anyone) draws the portrait instead: never a path from the URL +const SCENES = ['coding', 'writing', 'journey', 'gwm', 'open-source', 'contact'] +const sceneSrc = computed(() => + props.scene && SCENES.includes(props.scene) ? `/images/lowpoly/og/scene-${props.scene}.png` : undefined, +) const handle = computed(() => props.handle.replace(/^@/, '')) const facts = computed<{ icon: keyof typeof OG_ICONS, label: string, kind: 'ok' | 'person' | 'text', value: string }[]>(() => [ @@ -131,10 +98,8 @@ const facts = computed<{ icon: keyof typeof OG_ICONS, label: string, kind: 'ok'
-
- - - +
+
diff --git a/app/components/home/Explore.vue b/app/components/home/Explore.vue index 66b5c61..dfc7e69 100644 --- a/app/components/home/Explore.vue +++ b/app/components/home/Explore.vue @@ -10,6 +10,8 @@ interface Slide { title: string description: string cta?: { label: string; href: string; icon: string } + /** Its low-poly scene of the mascot (public/images/lowpoly/scene-[-light].png) */ + scene: string } const slides = computed(() => [ @@ -22,18 +24,21 @@ const slides = computed(() => [ href: 'mailto:hello@kbrdn.dev', icon: 'i-heroicons-envelope', }, + scene: 'collab', }, { label: t('explore.labels.crafting'), title: t('explore.slides.crafting.title'), description: t('explore.slides.crafting.description'), cta: { label: 'gwm', href: 'https://github.com/kbrdn1/gwm-cli', icon: 'i-simple-icons-github' }, + scene: 'coding', }, { label: t('explore.labels.blog'), title: t('explore.slides.blog.title'), description: t('explore.slides.blog.description'), cta: { label: t('explore.slides.blog.cta'), href: '/blog', icon: 'i-heroicons-document-text' }, + scene: 'writing', }, { label: t('explore.labels.sponsor'), @@ -44,26 +49,28 @@ const slides = computed(() => [ href: 'https://github.com/sponsors/kbrdn1', icon: 'i-heroicons-heart', }, + scene: 'sponsor', }, { label: t('explore.labels.motto'), title: t('explore.slides.motto.title'), description: t('explore.slides.motto.description'), + scene: 'reading', }, ]) const currentSlide = ref(0) const slide = computed(() => slides.value[currentSlide.value]) -// One voxel scene per slide (public/images/scenes): the dark ones in dark and glass mode -const SCENES = { - dark: ['city-gate', 'circuit-red'], - light: ['city-peach', 'city-domes', 'city-marble', 'village'], -} -const scene = computed(() => { - const list = SCENES[isDark.value ? 'dark' : 'light'] - return `/images/scenes/${list[currentSlide.value % list.length]}.webp` -}) +// Each slide's scene, its light variant in light mode, through IPX like the banners: the +// originals are 1.5 MB PNGs +const $img = useImage() +const scene = computed(() => + $img(`/images/lowpoly/scene-${slide.value?.scene}${isDark.value ? '' : '-light'}.png`, { + width: 960, + format: 'webp', + }), +) // Next slides in from the right, previous from the left (the timer always goes next) const direction = ref<'next' | 'prev'>('next') diff --git a/app/components/layout/Notch.vue b/app/components/layout/Notch.vue index 8b953bf..81db1f5 100644 --- a/app/components/layout/Notch.vue +++ b/app/components/layout/Notch.vue @@ -28,9 +28,7 @@ interface Menu { image: string } -const SCENES = '/images/scenes' const ME = '/images/me/portrait.webp' -const GWM = '/images/apps/gwm.webp' // The blog's menu lists its latest posts, fetched the first time it opens; each shows its // banner, through IPX (the originals weigh megabytes) @@ -54,14 +52,18 @@ watch(active, async (key) => { // Another language: its own posts, fetched again on the next opening watch(collection, () => (posts.value = [])) const colorMode = useColorMode() +// The low-poly scenes of the mascot (public/images/lowpoly, docs/LOWPOLY.md), through IPX like +// the banners: the originals are 1.5 MB PNGs. The backgrounds come light and dark +const lowpoly = (name: string) => + $img(`/images/lowpoly/${name}.png`, { width: 480, format: 'webp' }) +const lowpolyBg = (name: string) => lowpoly(isDarkMode(colorMode.value) ? `${name}-dark` : name) const banner = (src?: string) => src ? $img(blogBanner(src, isDarkMode(colorMode.value)), { width: 480, format: 'webp' }) : undefined // The Parcours menu shows the page's own banners (scripts/banners.ts), read from its data: the -// latest role's, the latest study's, the most recent by start like the page's timeline. No open -// source project has one, gwm's screenshot stays +// latest role's, the latest study's, the most recent by start like the page's timeline function latestBanner(entries: readonly { startDate: string; image?: string }[]) { const withBanner = entries.filter((entry) => entry.image) return withBanner.sort((a, b) => b.startDate.localeCompare(a.startDate))[0]?.image @@ -78,11 +80,11 @@ const menus = computed>(() => ({ text: t('navMenu.home'), links: [ { label: t('bio.title'), to: '/#about', image: ME }, - { label: t('blog.title'), to: '/#latest-posts', image: `${SCENES}/village.webp` }, - { label: t('skills.title'), to: '/#skills', image: `${SCENES}/circuit-red.webp` }, - { label: t('projects.title'), to: '/#projects', image: GWM }, + { label: t('blog.title'), to: '/#latest-posts', image: lowpoly('scene-writing') }, + { label: t('skills.title'), to: '/#skills', image: lowpoly('scene-coding') }, + { label: t('projects.title'), to: '/#projects', image: lowpoly('scene-gwm') }, ], - image: `${SCENES}/city-gate.webp`, + image: lowpolyBg('bg-world'), }, '/experience': { text: t('navMenu.experience'), @@ -97,30 +99,38 @@ const menus = computed>(() => ({ to: { name: 'experience', hash: '#education' }, image: careerBanner(STUDY_BANNER), }, - { label: 'Open source', to: { name: 'experience', hash: '#open-source' }, image: GWM }, + { + label: 'Open source', + to: { name: 'experience', hash: '#open-source' }, + image: lowpoly('scene-open-source'), + }, ], image: careerBanner(ROLE_BANNER) ?? ME, }, '/apps': { text: t('navMenu.apps'), links: [ - { label: 'gwm', to: '/apps#gwm', image: GWM }, - { label: 'vellum', to: '/apps#vellum', image: `${SCENES}/circuit-red.webp` }, - { label: 'LazyCurl', to: '/apps#lazycurl', image: '/images/apps/lazycurl.webp' }, + { label: 'gwm', to: '/apps#gwm', image: lowpoly('scene-gwm') }, + { label: 'vellum', to: '/apps#vellum', image: lowpoly('scene-vellum') }, + { label: 'LazyCurl', to: '/apps#lazycurl', image: lowpoly('scene-lazycurl') }, ], - image: GWM, + image: lowpolyBg('bg-studio'), }, '/activity': { text: t('navMenu.activity'), links: [ - { label: 'Open source', to: '/activity#activity-open-source-heading', image: GWM }, + { + label: 'Open source', + to: '/activity#activity-open-source-heading', + image: lowpoly('scene-open-source'), + }, { label: t('activityPage.feedTitle'), to: '/activity#activity-feed-heading', - image: `${SCENES}/city-domes.webp`, + image: lowpoly('scene-deploy'), }, ], - image: `${SCENES}/circuit-red.webp`, + image: lowpoly('scene-gamedev'), }, '/blog': { text: t('navMenu.blog'), @@ -132,12 +142,12 @@ const menus = computed>(() => ({ })), { label: t('blog.viewAll'), to: '/blog' }, ], - image: banner(posts.value[0]?.bannerImage) ?? `${SCENES}/village.webp`, + image: banner(posts.value[0]?.bannerImage) ?? lowpoly('scene-writing'), }, '/contact': { text: t('navMenu.contact'), links: [ - { label: t('contactPage.write'), to: '/contact', image: ME }, + { label: t('contactPage.write'), to: '/contact', image: lowpoly('scene-contact') }, { label: 'hello@kbrdn.dev', to: 'mailto:hello@kbrdn.dev', @@ -146,7 +156,7 @@ const menus = computed>(() => ({ }, ...(cvUrl.value ? [{ label: t('contactPage.cv'), to: cvUrl.value, external: true }] : []), ], - image: ME, + image: lowpoly('scene-contact'), }, gwm: { text: t('navMenu.gwm'), @@ -155,7 +165,7 @@ const menus = computed>(() => ({ { label: 'GitHub', to: 'https://github.com/kbrdn1/gwm-cli', external: true }, { label: t('navMenu.gwmApps'), to: '/apps#gwm' }, ], - image: GWM, + image: lowpoly('scene-gwm'), }, })) diff --git a/app/components/ui/PixelScene.vue b/app/components/ui/PixelScene.vue index d377826..adaa7d3 100644 --- a/app/components/ui/PixelScene.vue +++ b/app/components/ui/PixelScene.vue @@ -1,6 +1,6 @@