Part 1 — asset versioning: every local <script src> and <link rel=stylesheet> on the
tracked pages now carries ?v=<sha256-of-the-file> (10 hex), computed at BUILD time from
the file's bytes by work/build-asset-versions.py — never hand-typed, and idempotent
(re-running with no file change rewrites nothing). This is what makes an edited
focus-rule.js / photo-upload.js a new URL instead of a stale cache hit.
Part 2 — language facet: a language now counts for the Language filter only when its
0-5 self-assessment is >= LANG_FACET_MIN (3 = 'works in it'). The constant sits beside
the 15-point rule in the page script and its mirror in build-directory.py. Nothing
stored changes; the card's languages chart still prints every language above 0, the
search box is unchanged, and the facet stays an exact membership test against the
card's value. A '1' language no longer offers the person under that language.
Publishes the accumulated freelancer-directory work (six held passes) plus the
contact-modal success copy the user supplied today.
1. Anonymous-question button + modal — LIVE. ODOO_EXPERTS_QUOTE_WEBHOOK is now
set, so the per-card "Ask question anonymously" button and its modal render.
The button is added by script only on cards marked data-relay (a record we can
relay to); the modal posts {freelancers:[{id,name}], brief, email} to the
odoo_experts_quote route. With the constant empty the surface stays dark and
no dead button is rendered.
2. Description cap 230 — the "About you" textarea is capped at 230 characters
with a live counter (DESC_MAX_CHARS); the same cap truncates a longer legacy
value on the render path. The matching change to the owner's edit form lives
in odoo-community-developers/admin/index.html (included here).
3. Removal of "Ask us to find one" — the dead call to action is gone.
4. Copy removals — the invitation swap, four lede sentences, the whole invite
section, and the Start line are removed.
5. Two "Get listed" buttons + the application form as a modal — the ONE
#applyForm stays inline in the served HTML (works with JS off) and is moved
into #applyModal once when the script runs; the data-apply-open anchors then
open the dialog instead of jumping, and are working links with JS off.
6. Best Practices accordion — 11 <details name="best-practices">, all CLOSED on
load; at most one open via the shared name attribute plus a JS fallback.
Contact-modal success copy (user, 2026-11-03): the receipt clause is kept and the
user's two promises are used in their words —
"Thank you — we have your question. We will contact the freelancer, and as soon
as we have their answer we will come back to you."
The sending and error states are unchanged. NOTE: both promises (the relay and
the reply) depend on the n8n workflow that does not exist yet — the endpoint is a
stub. The owner took that decision knowingly; the copy ships as asked.
QC: verify-html.py 0 · check-inline-js OK · competitor QC exit 3, 74 site-wide
pre-existing, 0 in our files · impeccable detect --no-advisory dir 14 (0 new).
No form submitted.
- card count reads "4 clients on ODOO4projects" (no "— a number we recorded"); the
lede says "our own count" for the same reason. Admin form hint left alone (it is
an instruction to the operator, not a claim to the visitor).
- removed "15 points over five areas — nobody can be maximum in everything." from
the card (build + runtime); the forms keep their own explanation.
- removed the visible axis digit (dev-axis-val) from both renderers; the five
squares stay and each row carries its value as role="img" + aria-label="N of 5".
- removed every ", as declared" (10 on the served page) and rewrote the trust block
so it no longer promises a label that no longer exists.
- "Focus profile" -> "Skill profile" on the card (build + runtime) and in both form
legends. The separate `Focus` payload field is untouched.
- languages render through the same dev-axes chart as the skill profile (one
component, two labels; no digit either).
- OCA becomes a clickable "OCA Member Profile" badge in the card header, once; the
links row no longer carries it. Same predicate drives the re-added OCA filter
(OCA_link under https://apps.odoo-community.org/community/), which is a facet in
the existing filter row, renders only when the data supports it, and filters
rather than ranks.
The user's spec (2026-11-03): "remove the ODOO versions. people are lying anyways";
"15 points to distribute between functional system modules integration ai"; "the
languages should be self assessed with 0 can not speak and 5 native. we have the
languages english german and spanish"; "lets limit the focus on the form to 70 chars";
"lets remove the member OCA question … use the OCA link to determine if someone is a
member … show the oca link on top of all links and make it apart with a color, also
dont show the link itself just a button or a chip"; "the only quality signal we can give
and verify is how many projects the freelancer brought to our platform"; "no sorting we
keep the no rank policy"; "get the sort on the page out."
REMOVED (both forms + the filter + the hints):
- `odoo_versions` — the webhook never carried it, so every value was silently dropped
(re-verified: the payload has no `odoo_versions` field).
- `specialities` — replaced by the focus profile.
- the OCA-member question — membership is now DERIVED from `OCA_link`.
ADDED:
- Five focus sliders (functional, system, modules, integration, ai), each 0-5, from ONE
constant (`FOCUS_AXES`), on both forms. Live running total ("12 of 15 points"); the
submit is BLOCKED with an inline warning unless the five total exactly 15. The 15-point
budget of a possible 25 is what stops anyone claiming maximum everywhere, and the form
says so. No-JS: a `<noscript>` block swaps the sliders for five number inputs.
- Three language sliders (English, German, Spanish), each 0-5 (0 = cannot speak,
5 = native), as SEPARATE numeric wire fields — no CSV, no 114-name ISO dataset, no
free text. Preset to 0 (the user's decision, stated): an untouched slider records
"cannot speak" rather than a blank.
- `Focus` capped at 70 characters on both forms with a live counter; the render path
truncates a longer legacy value to 70 with a visible ellipsis.
- The card, in the user's order: photo+name · "N clients on ODOO4projects" (once, with
weight) · Focus · the freelancer's own words · the focus-profile chart (one row per
axis the payload carries, five squares) · the language line ("English 4 · German 5") ·
the links, with the OCA profile chip FIRST and APART (a label, never the raw URL;
"(as declared)") · the anonymous-contact button (JS-only — dark).
- The anonymous-contact modal: sends `{freelancers:[{id,name}], brief, email}` for the
one card, opens only when `ODOO_EXPERTS_QUOTE_WEBHOOK` is set (still empty — ships
dark), and says plainly that the email goes to us, never to the freelancer.
- OCA membership from `OCA_link`, matched on host + PATH PREFIX
(`https://apps.odoo-community.org/community/`), never the bare domain.
ORDER: the client-side `Name` sort is gone from BOTH renderers — the endpoint's order is
rendered as received, so cards reshuffle between loads (the server randomises on purpose).
Nothing is ever ordered by the client count.
Also: the count is rendered ONCE (the payload has one field for it — "clients" and
"projects" are the same number here), `0` renders (a fact) while absent/null renders
nothing, and the legacy `languages`/`specialities` fields are left in the payload but no
longer read.
QC: verify-html.py 0 · check-inline-js OK · test-directory-render.py PASS · competitor QC
exit 3, 74 site-wide pre-existing, 0 in our files · impeccable detect --no-advisory
dir 14 (0 added) / admin 7 (0 added) · 375 px 0 overflow · no form submitted by hand.
- Search box: substring, case- and accent-insensitive, over Name, description,
Focus, specialities and language names; combines with every filter (AND).
- Filters: OCA membership (any / member / not a member), Language (canonical
names only), plus Country/Speciality; a project MINIMUM ('N+ projects'),
rendered only when a threshold can narrow the list.
- Languages standardised: a closed ISO 639-1 checklist on both forms, with the
proficiency as a SEPARATE axis (language_levels, 'Spanish:beginner'). A stored
'Spanish - beginners' now maps to the language 'Spanish' and never becomes a
filter option.
- Fix: .dev-card/.dir-filters set display, which beat the UA [hidden] rule, so
filtered-out cards and the JS-off filter bar stayed visible.
- Deterministic order by Name (the endpoint's order alternates between reads).
- Card states plainly that a photo shows only when it is a link (the stored
value is base64 and is not rendered); admin Image field says the same.
- specialities stays free text.
Country was free text, so one country could be entered as Germany / DE /
Deutschland / germany and split the directory filter four ways. Both the
application and the admin form now submit from a native <select> of the
249 ISO 3166-1 alpha-2 codes: the option shows the English name, the value
is the code. No free-text fallback and no "other" escape hatch. Required,
with an empty "— choose —" first option. The directory filter shows the
English name for the stored code, so one country is one option.
Non-trivial, non-UI: the submitted value format lives in one constant per
page, COUNTRY_VALUE_FORMAT ("code" | "name") — switching to full country
names is a one-line change if the n8n workflow expects names, not codes.
The webhook field name stays exactly "Country".
- F2: 'We publish exactly what you write' -> 'We publish what you write —
after we check it: your photo, your email address and for spam' (the page's
own consent line + photo check + spam gate made the absolute false)
- claims sweep: drop the 'no claim we cannot back' meta-claim and the
'not through an agency' absolute; state the individual rule as the
declaration we ask for, and make the card's individual tag read
'(as declared)' to match the OCA tag
- F3: directory lede now says the individual rule is a declaration, not a fact
- F4: 'How this works' 110 -> 44 words (three short lines, no relay promise)
- F5: count framed as 'a number we recorded' (owner-typed, not derived);
photo hint agrees with its '(optional)' label; kicker 'ODOO COMMUNITY
FREELANCERS' -> 'ODOO FREELANCERS' and the meta/og drop 'Odoo Community'
so an Enterprise client is not invited to self-exclude
- F1: the request and invite forms SHIP DARK — their endpoint constants are
empty and the forms stay hidden behind a plain 'not open yet' line
(no submit, so no false confirmation); the invite copy carries the
one-click opt-out line the ICP asked for before the form returns
- languages/specialities now POST as comma-separated STRINGS ("English,German"),
not arrays; the build script splits them into separate filter options and tags.
- the client request form is no longer inert: it posts to odoo_experts_quote with
an explicit freelancer pick-list (never an implicit all) and a client address.
- new invite-a-friend form posts to odoo_experts_invite_friend (sender name +
friend email); we send the invitation, not the sender's address.
- both new forms show success only on a returned 200 (n8n answers 200 before the
workflow runs) and promise no timing.
- build-directory.py: render the pick-list, fix the country option casing.
- pages stay drafts (noindex, not in sitemap/llms, no inbound links).
The live record carries placeholder junk in Linkedin/upwork/Image ('1',
'ieivmermi', 'gineoirn'). With type=url the whole form went invalid and the owner
could not save anything. type=text inputmode=url keeps the URL intent and the
mobile keyboard without hard-blocking on data the owner is trying to correct.
- hide the inert client request form; announce it as coming soon with an email
fallback (no reachable error state, no submit that refuses a completed brief)
- soften the contact claim to 'where a freelancer published their details' and
make a card with no contact say so instead of showing an empty space
- move the admin add/edit panel off the public page to its own draft page
(/odoo-community-developers/admin/), unlinked, reachable only by URL
- admin edit: load-and-confirm the record before writing, and read-modify-write
the booleans so an untouched OCA_member/is_individual keeps its current value
- application form: add Country + Odoo versions (the filters' inputs), GitHub and
OCA profile URLs, and an explicit email-publication consent line
- tighten 'How this works' from ~120 words to two short options + one caveat
- build-directory.py: skip records with no Name; render GitHub/OCA links and the
no-contact line; read Country via alias