Il vous reste trente minutes avant la démo, et votre application Shiny ressemble toujours à du Bootstrap 5 sorti du carton.
On est tous passés par là. L’analyse est solide, le modèle tourne bien, les tableaux sont propres. Et puis quelqu’un du service communication passe derrière votre écran et vous fait remarquer, très poliment, que « ça ne fait pas trop nous ». Il a raison. Votre app est bleuâtre, votre entreprise est orange. Il existe quelque part une charte graphique de 60 pages que vous n’avez jamais ouverte, et que vous n’avez aucune intention de lire avant 14h.
D’où la question naïve qu’on s’est posée chez ThinkR : le site de l’entreprise porte déjà la marque. Les couleurs sont dans son CSS, juste là, servies publiquement en HTTP. Pourquoi ne pas simplement aller les chercher ?
C’est {css2r}, et la petite application Shiny qui l’emballe, Shiny Copy. Vous donnez une URL, elle vous rend un appel bslib::bs_theme(). Elle est en ligne sur connect.thinkr.fr/css2r. Ouvrez-la dans un autre onglet, collez l’adresse de votre entreprise, et revenez. L’article sera toujours là.
Ça fonctionne. Et le plus intéressant dans cet article, ce n’est pas que ça fonctionne. C’est tout ce que le web refuse de vous donner en chemin. Accrochez-vous, on va parler CSS. 🎨
Trente secondes : une URL en entrée, un thème en sortie
Table des matières
remotes::install_github("ThinkR-open/css2r")
library(css2r)
thinkr <- css2r$new(url = "https://thinkr.fr")
#> ✔ Internet ok
#> ✔ html page downloaded
#> ✔ CSS links extracted
#> ✔ CSS links filtered
#> ✔ CSS downloaded
#> ✔ Colors extracted successfully
#> ✔ Colors analyzed successfully.
#> ℹ No Google Fonts detected.
#> ✔ Shiny theme code generated.
Neuf étapes, quelques secondes, et l’objet contient tout ce qu’il a trouvé :
thinkr$top_colors
#> $white_black
#> Color Count
#> 32 #FFFFFF 1
#>
#> $top_colors
#> Color Count
#> 1 #38404C 133
#> 2 #0046C8 72
#> 3 #F05622 40
#> 4 #20B8D6 23
cat(thinkr$shiny_code)
#> fluidPage(
#> theme = bslib::bs_theme(
#> bg = "#FFFFFF",
#> fg = "#38404C",
#> primary = "#38404C",
#> secondary = "#0046C8"
#> ),
#> h1("Hello World primary", class = "text-center text-secondary"),
#> h1("Hello World secondary", class = "text-center text-primary")
#> )
Copier, coller dans votre app_ui.R, terminé.
Et si vous préférez cliquer plutôt que taper, c’est exactement ce que fait la version hébergée : pastilles de couleur, bouton de copie, et une maquette Bootstrap en direct qui montre à quoi ressemblerait votre application avant d’avoir écrit la moindre ligne. En local, css2r::run_app() lance la même chose.
Maintenant, regardez ce thème à nouveau. Regardez-le vraiment.
#F05622, c’est l’orange ThinkR. Celui du logo, des slides, des mugs. Il arrive troisième, avec 40 occurrences. Il n’apparaît nulle part dans le thème généré. Ce que {css2r} a retenu comme primary, c’est #38404C, un gris ardoise foncé : la couleur du texte courant, qui par construction se retrouve partout.
Nous avons lancé notre propre outil sur notre propre site, et il n’a pas trouvé notre propre couleur de marque. Ça mérite une explication, et cette explication constitue le reste de l’article.
Sous le capot : neuf étapes et une expression régulière
La classe R6 css2r est volontairement minuscule. Le pipeline est linéaire, et chaque étape peut être appelée à la main avec on_initialize = FALSE :
check_internet(), via{curl}download_html(), via{rvest}extract_css_links(), toutes les balises<link rel="stylesheet">filter_css_links(), on ne garde que celles du même domainedownload_css_files(), via{httr}extract_colors(), le cœur de la machineanalyze_colors(), on sépare les neutres des couleurs de marquedetect_google_fonts(), lecture des paramètres d’URL Google Fontsgenerate_shiny_code(), assemblage de l’appelbs_theme()
L’étape 6, celle qui fait tout le vrai travail, tient en cinq lignes :
pattern <- "#[0-9A-Fa-f]{6}"
matches <- gregexpr(pattern = pattern, text = self$css_content, perl = TRUE)
colors_hex <- regmatches(x = self$css_content, m = matches) |>
unlist() |>
toupper()
table_colors_hex <- colors_hex |> table() |> as.data.frame(stringsAsFactors = FALSE)
On cherche tous les codes hexadécimaux à six chiffres, on compte les occurrences, on trie. Ensuite analyze_colors() met #FFFFFF et #000000 de côté comme neutres, garde les quatre premières, et generate_shiny_code() attribue fond et texte par luminance relative :
hex_luminance = function(hex) {
hex <- sub("^#", "", hex)
r <- strtoi(substr(hex, 1, 2), 16L) / 255
g <- strtoi(substr(hex, 3, 4), 16L) / 255
b <- strtoi(substr(hex, 5, 6), 16L) / 255
0.2126 * r + 0.7152 * g + 0.0722 * b
}
La couleur la plus claire devient bg, la plus foncée fg, la plus fréquente primary, la deuxième secondary. Voilà toute la théorie.
C’est une heuristique, et une bonne : elle produit un thème utilisable sur une large part des sites ordinaires. Mais elle repose sur trois hypothèses concernant le web, et le web moderne les casse toutes les trois.
Pourquoi c’est plus dur qu’il n’y paraît
Pour rendre tout ça concret plutôt que théorique, on a pointé l’extraction vers une poignée de sites réels en août 2026, et on a compté ce qui était réellement atteignable. Les résultats sont plus parlants que n’importe quel schéma.
| Site | Liens CSS | Même domaine | <style> inline |
Hex 6 chiffres | Hex 3 chiffres | rgb() |
var(--…) |
Verdict |
|---|---|---|---|---|---|---|---|---|
| thinkr.fr | 4 | 3 | 3 blocs (8,3 ko) | 309 | 234 | 70 | 37 | marche, mauvais primary |
| lemonde.fr | 2 | 2 | 0 | 805 | 140 | 82 | 707 | passe, thème tout gris |
| posit.co | 5 | 4 | 1 | 137 | 42 | 72 | 1 545 | marche, rate la palette |
| tailwindcss.com | 2 | 2 | 0 | 973 | 46 | 7 | 8 371 | 300 couleurs uniques, aucun signal |
| stripe.com | 5 | 0 | 0 | n/a | n/a | n/a | n/a | échec total |
Trois modes de défaillance bien distincts se cachent dans ce tableau.
1. Le CSS qu’on ne peut pas atteindre
filter_css_links() ne conserve que les feuilles de style servies depuis le même domaine que la page :
keep(.p = ~ domain(.x) == self$domain)
Cette règle existe pour une bonne raison. Sur thinkr.fr, elle écarte à juste titre un thème highlight.js hébergé sur cdnjs, une palette de coloration syntaxique qui n’a rien à voir avec notre marque et qui aurait pollué le comptage avec des violets aléatoires.
Sur stripe.com, la même règle écarte absolument tout. Les cinq feuilles de style vivent sur b.stripecdn.com. Même entreprise, autre nom d’hôte, et {css2r} repart les mains vides :
External stylesheets only. This website loads 5 stylesheet(s), but all of them are served from another domain, so ShinyCopy cannot tell brand CSS from third-party CSS.
Toute organisation qui sert ses ressources depuis un CDN dédié, c’est-à-dire la plupart des grandes organisations, nous est invisible.
Il y a là une subtilité qui nous a coûté un bug, et elle mérite d’être racontée parce que c’est typiquement le genre d’erreur qui se cache derrière un message plausible. urltools::domain() ne rend pas le domaine enregistrable malgré son nom : il rend le host complet, sous-domaine inclus. Pour un site qui redirige son apex vers www, la page téléchargée et les feuilles qu’elle référence se retrouvaient donc sur www.exemple.com, pendant que le filtre comparait toujours avec le exemple.com saisi par l’utilisateur. Toutes les feuilles étaient écartées, et l’application accusait le CSS inline avec aplomb. Le filtre compare désormais l’eTLD+1, et résout tout contre l’URL réellement atteinte après redirections, ce qui conserve le comportement utile (cdnjs part toujours) sans la balle dans le pied.
Il y a ensuite le CSS qui n’apparaît dans aucun <link>. thinkr.fr embarque trois blocs <style> inline, soit 8,3 ko de CSS critique injecté directement dans le HTML pour accélérer le premier affichage. C’est une pratique de performance parfaitement standard, et extract_css_links() passe juste à côté, puisqu’il ne regarde que link[rel='stylesheet']. Shiny Copy a au moins l’honnêteté de vous le dire :
No stylesheet found. No
<link rel="stylesheet">tag was found on thinkr.fr. This usually means the site ships its CSS inline in the HTML, or renders the page with JavaScript. ShinyCopy supports neither yet.
Et enfin, les pages qui n’ont aucun HTML exploitable au chargement parce que tout est rendu côté client en JavaScript. {rvest} récupère le document tel que le serveur l’a envoyé, pas tel que le navigateur finit par le peindre. Sur une single-page app, il n’y a souvent rien à lire.
2. Les couleurs qu’on ne sait pas lire
Celui-là est mon préféré, parce que l’outil échoue silencieusement et partiellement.
#[0-9A-Fa-f]{6} capture les hexadécimaux à six chiffres. Rien d’autre. Sur thinkr.fr, cela signifie qu’on voit 309 déclarations de couleur et qu’on ignore tranquillement 234 hexadécimaux à trois chiffres (#fff, #333) ainsi que 70 appels rgb() / rgba(). Environ quarante pour cent de l’information colorimétrique de notre propre site part à la poubelle, et rien dans la sortie ne vous en informe. Ajoutez hsl(), qui apparaît 60 fois sur lemonde.fr, plus les couleurs nommées CSS type rebeccapurple, et l’angle mort s’élargit encore.
Le correctif n’a rien de conceptuellement difficile : normaliser toutes les notations de couleur en hexadécimal avant de compter. C’est simplement du travail qui n’a pas encore été fait.
Le problème plus profond, ce sont les propriétés personnalisées CSS. Regardez à nouveau la colonne var(--…). Sur tailwindcss.com, on compte 8 371 références à des variables CSS, et 1 545 sur posit.co. Un design system moderne déclare sa couleur de marque exactement une fois :
:root { --brand-primary: #F05622; }
… puis l’utilise mille fois via var(--brand-primary). Notre compteur de fréquence voit cette couleur une seule fois. Pendant ce temps, un gris déclaré en dur dans quinze vieux composants marque quinze points. On ne mesure pas l’importance, on mesure la dette technique de la feuille de style.
3. La fréquence n’est pas l’identité
Ce qui nous ramène à l’orange ThinkR, et à l’hypothèse qui sous-tend toute l’approche : la couleur la plus fréquente est la plus importante.
C’est tout simplement faux. La couleur la plus fréquente d’une feuille de style, c’est presque toujours la plomberie de l’interface : gris de texte, gris de bordure, gris de champ désactivé. Les couleurs de marque sont utilisées avec parcimonie, et c’est précisément tout l’intérêt d’une couleur de marque. Un orange de logo présent sur trois boutons porte infiniment plus d’identité qu’un #38404C appliqué à tous les paragraphes du site.

frequence
lemonde.fr en est l’illustration la plus pure, et un cas pire que notre propre site. Toutes les étapes du pipeline réussissent, aucune erreur n’est levée, et voilà ce qui sort :
$top_colors
Color Count
1 #2A303C 77
2 #E8EAEE 62
3 #F5F6F8 40
4 #464F5F 25
bslib::bs_theme(bg = "#FFFFFF", fg = "#2A303C",
primary = "#2A303C", secondary = "#E8EAEE")
Les quatre sont des gris. Le bleu du Monde, #015AAD, se situe au rang 10 avec 17 occurrences : les rangs 1 à 9 sont des neutres, tous sans exception. Et secondary tombe sur #E8EAEE, soit un ratio de contraste de 1,09 sur le fond blanc. Un .btn-secondary serait, très littéralement, invisible.
Sur tailwindcss.com, les deux premiers sont le blanc (214) et le noir (129), et comme analyze_colors() les met délibérément de côté, on retombe sur #030712, qui est… un noir très légèrement moins noir. En dessous, 300 couleurs uniques sans écart de fréquence significatif, parce qu’un design system publie l’intégralité de sa palette dans un seul fichier.
Une dernière conséquence mérite d’être nommée. Sur thinkr.fr, #38404C est à la fois la couleur la plus fréquente et la plus foncée. Elle devient donc fg et primary en même temps. Un élément .text-primary a alors exactement la couleur du texte courant : techniquement valide, visuellement inutile. {css2r} ne fait aucun contrôle de contraste, ni aucune déduplication entre les rôles.
Alors on a demandé à un humain
Voici la conclusion pragmatique à laquelle on est arrivés en construisant Shiny Copy : si la machine ne sait pas distinguer de façon fiable une couleur de marque d’une couleur de plomberie, il ne faut pas la laisser décider seule.
L’application extrait les quatre meilleurs candidats, génère sa proposition, puis affiche des pastilles cliquables pour les rôles primary et secondary. Un clic reconstruit le thème de l’aperçu en direct :
observeEvent(input$preview_primary, {
rv$preview_primary <- input$preview_primary
session$setCurrentTheme(
bslib::bs_theme_update(
rv$site$shiny_theme,
primary = rv$preview_primary,
secondary = rv$preview_secondary
)
)
})
Le classement par fréquence cesse d’être une réponse pour devenir une présélection. Vous obtenez bien votre orange ThinkR. Il faut juste cliquer dessus, ce qui prend environ une seconde, et c’est vous qui savez à quoi ressemble votre marque.
Sauf qu’une présélection ne vaut jamais mieux que le classement qui l’a produite, et lemonde.fr montre exactement où ça casse : les quatre pastilles proposées sont quatre gris, et le bleu de marque au rang 10 n’en fait pas partie. Il ne reste rien à rattraper pour l’humain. S’en remettre à l’utilisateur corrige un classement imparfait ; ça ne fait rien pour un classement faux dès la première ligne.
Ce qu’il faudrait pour aller plus loin
Les limites ci-dessus ne sont pas des mystères, ce sont une feuille de route. Grossièrement par ordre de rentabilité :
- Classer par chroma, pas seulement par comptage. Le correctif le moins cher pour l’effet visible le plus grand. Écarter les quasi-neutres avant de choisir
primary, en se contentant de l’écart entre les canaux RVB :chroma <- function(hex) { v <- c(strtoi(substr(hex, 2, 3), 16L), strtoi(substr(hex, 4, 5), 16L), strtoi(substr(hex, 6, 7), 16L)) max(v) - min(v) }#38404Cmarque 18,#F05622marque 206. La séparation n’a rien de subtil. Appliqué à nos deux exemples,primary/secondarypasserait de#38404C/#0046C8à#0046C8/#F05622sur thinkr.fr, et de deux gris à#015AADsur lemonde.fr. Ça règle au passage la collisionprimary == fggratuitement, puisqu’un neutre foncé ne peut plus gagner. - Normaliser les notations de couleur. Une fonction
parse_css_color()convertissant#fff,rgb(),rgba(),hsl()et les couleurs nommées vers un hexadécimal canonique récupérerait immédiatement les ~40 % qu’on perd aujourd’hui. - Lire les blocs
<style>inline. Unhtml_nodes("style") |> html_text()de plus, et une bonne quantité de CSS critique cesse d’être invisible. - Gérer les CDN d’assets. Comparer les domaines enregistrables a réglé le cas apex/www, mais
b.stripecdn.comest réellement un domaine différent destripe.com, et aucune comparaison de chaînes ne comblera cet écart. Il faut une autre règle : une liste blanche, ou pondérer les feuilles plutôt que les exclure d’emblée. - Résoudre les propriétés personnalisées CSS. Parser les déclarations
--var: #hex, puis compter les usages devar(--var)à leur crédit. C’est de loin le plus gros gain de fidélité disponible, et la seule façon de rendre l’outil réellement opérant sur les design systems modernes. - Pondérer par le contexte, pas par le comptage. Une couleur sur un fond de
.btnne vaut pas un arrêt de dégradé dans une image de bannière. Cela suppose un véritable parseur CSS au lieu d’une expression régulière. Un projet nettement plus lourd, et le moment où le « petit outil utile » devient un produit. - Vérifier le contraste avant de générer. Un rapide ratio WCAG sur le couple
bg/fg, et une règle empêchantprimaryde s’effondrer surfg. - Rendu headless pour les pages très chargées en JavaScript, via
{chromote}. Puissant, lourd, et toute une autre catégorie de problèmes de déploiement.
On a publié sans rien de tout cela, et volontairement. Un outil qui résout le cas courant en cinq secondes et vous dit honnêtement quand il n’y arrive pas est plus utile qu’un outil qui ne sort jamais du labo. Et chacune des fenêtres d’erreur de Shiny Copy existe pour que vous le sachiez immédiatement, plutôt que de livrer une app grise en vous demandant pourquoi.
Essayez, cassez, dites-nous
L’application est en ligne et gratuite, sans rien installer :
Et si vous la préférez dans vos propres scripts :
remotes::install_github("ThinkR-open/css2r")
css2r::run_app()
Pointez-la vers le site de votre entreprise. Si ça marche, vous venez d’économiser un après-midi de charte graphique. Si ça échoue, on serait sincèrement curieux de savoir lequel des trois modes de défaillance ci-dessus vous est tombé dessus. Le code est sur GitHub, les issues et les pull requests sont les bienvenues, et le parseur de notations de couleur ferait une très jolie première contribution. 😉
Et si ce dont vous avez besoin, c’est d’une application Shiny correctement brandée, correctement testée et correctement déployée, parlons-en. Cette partie-là, on la fait à la main.
Chiffres mesurés en août 2026 ; les sites évoluent, vos résultats peuvent différer.


