Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
145 changes: 145 additions & 0 deletions .claude/skills/lowpoly-image/SKILL.md
Original file line number Diff line number Diff line change
@@ -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/<nom>-last.md \
-i $L/mascot-sheet.png -i $R/sheet-head.png -i $R/k-light.png -i $L/scene-coding.png \
- < $D/<nom>.txt > $D/<nom>.jsonl 2> $D/<nom>.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/<fichier>` ;
- 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/<thread>/` : 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/<fichier> -crop <w>x<h>+<x>+<y> +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/<fichiers>
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.
8 changes: 5 additions & 3 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
57 changes: 11 additions & 46 deletions app/components/OgImage/Default.satori.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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',
Expand All @@ -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 }[]>(() => [
Expand Down Expand Up @@ -131,10 +98,8 @@ const facts = computed<{ icon: keyof typeof OG_ICONS, label: string, kind: 'ok'

<!-- The drawn portrait, or the page's scene, and the facts -->
<div class="flex items-center" style="margin-top: 20px; gap: 28px;">
<div v-if="sceneBox?.cells.length" class="flex items-center justify-center" style="box-sizing: border-box; width: 290px; height: 168px; overflow: hidden; background: #0f0f12; border: 1px solid rgba(255,255,255,0.07); border-radius: 12px;">
<svg width="208" height="156" :viewBox="`0 0 ${sceneBox.w} ${sceneBox.h}`" shape-rendering="crispEdges">
<rect v-for="(cell, i) in sceneBox.cells" :key="i" :x="cell.x" :y="cell.y" :width="cell.w ?? 1" :height="cell.h ?? 1" :fill="cell.fill" :fill-opacity="cell.opacity" />
</svg>
<div v-if="sceneSrc" class="flex" style="box-sizing: border-box; width: 290px; height: 168px; overflow: hidden; background: #0f0f12; border: 1px solid rgba(255,255,255,0.07); border-radius: 12px;">
<img :src="sceneSrc" alt="" width="290" height="168" style="width: 290px; height: 168px; object-fit: cover;" />
</div>
<div v-else class="flex items-end justify-center" style="box-sizing: border-box; width: 290px; height: 168px; overflow: hidden; background: #0f0f12; border: 1px solid rgba(255,255,255,0.07); border-radius: 12px;">
<img :src="avatar" alt="" width="196" height="196" style="width: 196px; height: 196px; margin-bottom: -24px;" />
Expand Down
25 changes: 16 additions & 9 deletions app/components/home/Explore.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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-<scene>[-light].png) */
scene: string
}

const slides = computed<Slide[]>(() => [
Expand All @@ -22,18 +24,21 @@ const slides = computed<Slide[]>(() => [
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'),
Expand All @@ -44,26 +49,28 @@ const slides = computed<Slide[]>(() => [
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')
Expand Down
Loading
Loading