diff --git a/components/BioEditor.tsx b/components/BioEditor.tsx
index dca5287..c9800d3 100644
--- a/components/BioEditor.tsx
+++ b/components/BioEditor.tsx
@@ -10,6 +10,27 @@ import clsx from 'clsx'
import type { BioLanguage, BioPostContent } from '@/lib/bio-api'
import BioEditorToolbar from '@/components/BioEditorToolbar'
+// Bio length targets. The team agreed bios should land around 80 words; the
+// zones below give the author live feedback as they type. Boundaries are
+// inclusive on the green side so 70 and 90 read as "good", not "borderline".
+const BIO_TARGET_MIN = 70
+const BIO_TARGET_MAX = 90
+const BIO_WARNING_MIN = 60
+const BIO_WARNING_MAX = 100
+
+function countWords(text: string): number {
+ // Plain whitespace split is fine for the 6 European languages we support.
+ // CJK / Arabic would need Intl.Segmenter with granularity: 'word'.
+ const trimmed = text.trim()
+ return trimmed === '' ? 0 : trimmed.split(/\s+/).length
+}
+
+function wordCountZone(count: number): 'green' | 'orange' | 'red' {
+ if (count >= BIO_TARGET_MIN && count <= BIO_TARGET_MAX) return 'green'
+ if (count >= BIO_WARNING_MIN && count <= BIO_WARNING_MAX) return 'orange'
+ return 'red'
+}
+
type EditableFields = {
member_title: string
member_linkedin_url: string
@@ -41,6 +62,7 @@ export default function BioEditor({
const [isDirty, setIsDirty] = useState(false)
const [isSaving, setIsSaving] = useState(false)
const [isLoadingLang, setIsLoadingLang] = useState(false)
+ const [wordCount, setWordCount] = useState(0)
const [status, setStatus] = useState<
| { kind: 'idle' }
| { kind: 'success'; queued: string[] }
@@ -81,7 +103,13 @@ export default function BioEditor({
],
content: initialPost.content,
immediatelyRender: false,
- onUpdate: markDirty,
+ onCreate({ editor }) {
+ setWordCount(countWords(editor.getText()))
+ },
+ onUpdate({ editor }) {
+ markDirty()
+ setWordCount(countWords(editor.getText()))
+ },
editorProps: {
attributes: {
// rounded-b-md (not rounded-md) + border-t-0 because the
@@ -135,6 +163,12 @@ export default function BioEditor({
setCurrentTitle(post.title)
setFields(postToEditable(post))
editor?.commands.setContent(post.content, { emitUpdate: false })
+ // setContent with emitUpdate: false suppresses onUpdate by design
+ // (we don't want the language switch to flip isDirty), so sync the
+ // word count manually here.
+ if (editor) {
+ setWordCount(countWords(editor.getText()))
+ }
setIsDirty(false)
} catch (err) {
setStatus({ kind: 'error', message: (err as Error).message })
@@ -226,6 +260,22 @@ export default function BioEditor({
+
+
+ {t('wordCount', { count: wordCount })}
+
+
+ {t('wordCountTarget', { min: BIO_TARGET_MIN, max: BIO_TARGET_MAX })}
+
+
diff --git a/messages/de.json b/messages/de.json
index 002e961..6bbb4e8 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -305,6 +305,8 @@
"savedNoChange": "Gespeichert.",
"saveError": "Speichern fehlgeschlagen: {error}",
"unsavedSwitch": "Du hast nicht gespeicherte \u00c4nderungen. Verwerfen und Sprache wechseln?",
+ "wordCount": "{count, plural, =0 {Keine W\u00f6rter} one {# Wort} other {# W\u00f6rter}}",
+ "wordCountTarget": "Ziel {min}-{max} W\u00f6rter",
"notLinked": "Dein Konto ist mit keinem team_member-Profil verkn\u00fcpft. Wende dich an einen Administrator.",
"noLanguages": "Es sind noch keine Sprachversionen zur Bearbeitung verf\u00fcgbar.",
"loadError": "Diese Version deiner Bio konnte nicht geladen werden.",
diff --git a/messages/en.json b/messages/en.json
index 3072bf4..daf2dfe 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -305,6 +305,8 @@
"savedNoChange": "Saved.",
"saveError": "Could not save: {error}",
"unsavedSwitch": "You have unsaved changes. Discard them and switch language?",
+ "wordCount": "{count, plural, =0 {No words yet} one {# word} other {# words}}",
+ "wordCountTarget": "target {min}–{max} words",
"notLinked": "Your account isn't linked to a team_member profile. Contact an administrator.",
"noLanguages": "No language versions are available to edit yet.",
"loadError": "Could not load this version of your bio.",
diff --git a/messages/es.json b/messages/es.json
index 434b858..7129ed6 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -305,6 +305,8 @@
"savedNoChange": "Guardado.",
"saveError": "No se pudo guardar: {error}",
"unsavedSwitch": "Tienes cambios sin guardar. \u00bfDescartarlos y cambiar de idioma?",
+ "wordCount": "{count, plural, =0 {Sin palabras} one {# palabra} other {# palabras}}",
+ "wordCountTarget": "objetivo {min}-{max} palabras",
"notLinked": "Tu cuenta no est\u00e1 vinculada a un perfil de team_member. Contacta a un administrador.",
"noLanguages": "A\u00fan no hay versiones en otros idiomas para editar.",
"loadError": "No se pudo cargar esta versi\u00f3n de tu biograf\u00eda.",
diff --git a/messages/fr.json b/messages/fr.json
index 7726cf9..f7f95be 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -305,6 +305,8 @@
"savedNoChange": "Enregistr\u00e9.",
"saveError": "\u00c9chec de l'enregistrement : {error}",
"unsavedSwitch": "Vous avez des modifications non enregistr\u00e9es. Les abandonner et changer de langue ?",
+ "wordCount": "{count, plural, =0 {Aucun mot} one {# mot} other {# mots}}",
+ "wordCountTarget": "cible {min}-{max} mots",
"notLinked": "Votre compte n'est pas li\u00e9 \u00e0 un profil team_member. Contactez un administrateur.",
"noLanguages": "Aucune version linguistique n'est disponible \u00e0 la modification.",
"loadError": "Impossible de charger cette version de votre bio.",
diff --git a/messages/it.json b/messages/it.json
index e040ae6..2f88810 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -305,6 +305,8 @@
"savedNoChange": "Salvato.",
"saveError": "Impossibile salvare: {error}",
"unsavedSwitch": "Hai modifiche non salvate. Scartarle e cambiare lingua?",
+ "wordCount": "{count, plural, =0 {Nessuna parola} one {# parola} other {# parole}}",
+ "wordCountTarget": "obiettivo {min}-{max} parole",
"notLinked": "Il tuo account non \u00e8 collegato a un profilo team_member. Contatta un amministratore.",
"noLanguages": "Nessuna versione linguistica disponibile per la modifica.",
"loadError": "Impossibile caricare questa versione della tua bio.",
diff --git a/messages/pt.json b/messages/pt.json
index c9a1c2b..b68540e 100644
--- a/messages/pt.json
+++ b/messages/pt.json
@@ -305,6 +305,8 @@
"savedNoChange": "Guardado.",
"saveError": "N\u00e3o foi poss\u00edvel guardar: {error}",
"unsavedSwitch": "Tem altera\u00e7\u00f5es por guardar. Descart\u00e1-las e mudar de idioma?",
+ "wordCount": "{count, plural, =0 {Sem palavras} one {# palavra} other {# palavras}}",
+ "wordCountTarget": "alvo {min}-{max} palavras",
"notLinked": "A sua conta n\u00e3o est\u00e1 ligada a um perfil team_member. Contacte um administrador.",
"noLanguages": "Ainda n\u00e3o h\u00e1 vers\u00f5es lingu\u00edsticas dispon\u00edveis para edi\u00e7\u00e3o.",
"loadError": "N\u00e3o foi poss\u00edvel carregar esta vers\u00e3o da sua bio.",