From 0d69fcd0343dc8d2c79f5c8cbb8413e931656ff4 Mon Sep 17 00:00:00 2001 From: Allison Date: Mon, 27 Apr 2026 19:21:39 -0400 Subject: [PATCH] =?UTF-8?q?feat(marketing):=20A-2.7a=20footer=204-col=20+?= =?UTF-8?q?=20comparatif=20table=20+=20conformit=C3=A9=204=20pillars?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace placeholder _footer.html with full 4-column footer (Brand, Produit, Légal, Compte) — added aria-labelledby landmark, sr-only headings, address block, tel: link, external-link new-tab announcements, NBSP per OQLF - Add Comparatif section: DictIA vs MS Teams vs Otter.ai vs Whisper local on 6 criteria. Sourced (politiques publiques + grilles officielles 2026-04-27), trademark disclaimer, responsive table with sr-only caption + scope attrs - Add Conformité forteresse section: 4 pillars (OVH Beauharnois, mapped Loi 25 LPRPSP refs, compatible Cadre IA LGGRI, AGPL v3 verifiable Gitea link) - Soft hedges throughout: 'mappé', 'conçu avec', 'compatible' (no 'certifié', 'endossé', 'reconnu' — LPC art. 219 / Competition Act s. 52 hygiene) - 11 new tests covering footer landmarks, comparatif sourcing, conformité hedges, WCAG /70 contrast, and forbidden competitive claim regressions Co-Authored-By: Claude Opus 4.7 (1M context) --- static/css/marketing.css | 38 +++++- templates/marketing/_footer.html | 70 ++++++++-- templates/marketing/landing.html | 147 ++++++++++++++++++++ tests/test_marketing_landing_template.py | 162 +++++++++++++++++++++++ 4 files changed, 400 insertions(+), 17 deletions(-) diff --git a/static/css/marketing.css b/static/css/marketing.css index 3aef73f..d4515bb 100644 --- a/static/css/marketing.css +++ b/static/css/marketing.css @@ -576,6 +576,9 @@ .mt-8 { margin-top: calc(var(--spacing) * 8); } + .mt-10 { + margin-top: calc(var(--spacing) * 10); + } .mt-12 { margin-top: calc(var(--spacing) * 12); } @@ -963,6 +966,9 @@ .min-w-\[300px\] { min-width: 300px; } + .min-w-\[720px\] { + min-width: 720px; + } .min-w-full { min-width: 100%; } @@ -1279,6 +1285,11 @@ border-color: var(--border-primary); } } + .divide-brand-border { + :where(& > :not(:last-child)) { + border-color: #e6ebf2; + } + } .truncate { overflow: hidden; text-overflow: ellipsis; @@ -1708,6 +1719,9 @@ .bg-blue-600 { background-color: var(--color-blue-600); } + .bg-brand-b3\/10 { + background-color: color-mix(in oklab, #00c896 10%, transparent); + } .bg-brand-bg { background-color: #f7f9fc; } @@ -2123,6 +2137,9 @@ .pt-6 { padding-top: calc(var(--spacing) * 6); } + .pt-8 { + padding-top: calc(var(--spacing) * 8); + } .pt-\[62px\] { padding-top: 62px; } @@ -2518,12 +2535,6 @@ color: color-mix(in oklab, var(--color-white) 50%, transparent); } } - .text-white\/60 { - color: color-mix(in srgb, #fff 60%, transparent); - @supports (color: color-mix(in lab, red, red)) { - color: color-mix(in oklab, var(--color-white) 60%, transparent); - } - } .text-white\/70 { color: color-mix(in srgb, #fff 70%, transparent); @supports (color: color-mix(in lab, red, red)) { @@ -2569,6 +2580,9 @@ .italic { font-style: italic; } + .not-italic { + font-style: normal; + } .tabular-nums { --tw-numeric-spacing: tabular-nums; font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,); @@ -3178,6 +3192,13 @@ } } } + .hover\:bg-brand-bg\/50 { + &:hover { + @media (hover: hover) { + background-color: color-mix(in oklab, #f7f9fc 50%, transparent); + } + } + } .hover\:bg-emerald-700 { &:hover { @media (hover: hover) { @@ -4078,6 +4099,11 @@ grid-template-columns: repeat(4, minmax(0, 1fr)); } } + .md\:flex-row { + @media (width >= 48rem) { + flex-direction: row; + } + } .md\:gap-4 { @media (width >= 48rem) { gap: calc(var(--spacing) * 4); diff --git a/templates/marketing/_footer.html b/templates/marketing/_footer.html index 7f3f605..b6a2a79 100644 --- a/templates/marketing/_footer.html +++ b/templates/marketing/_footer.html @@ -1,13 +1,61 @@ -') + footer_html = body[footer_start:footer_end] + assert 'text-white/70' in footer_html, "Footer text must use /70 opacity for WCAG AA" + # Negative regression + assert 'text-white/40' not in footer_html, "Footer must not regress to /40 opacity" + assert 'text-white/50' not in footer_html, "Footer must not regress to /50 opacity" + + +def test_comparatif_section_present(): + """Comparatif section is present after Pricing with table + sourcing footnote.""" + client = app.test_client() + body = client.get('/').data.decode('utf-8') + assert 'comparatif-title' in body + assert 'COMPARATIF' in body + assert 'DictIA face aux solutions cloud' in body + # Sourcing footnote (LPC art. 219 hygiene) + assert 'sources publiques' in body, "Must disclose sources for competitor claims" + assert '2026-04-27' in body, "Must date the comparison" + # Trademark disclaimer + assert 'marques déposées' in body or 'marques déposées' in body, \ + "Trademark disclaimer required for competitor names" + + +def test_comparatif_table_has_4_competitors_and_6_criteria(): + """Comparatif table lists DictIA + 3 competitors over 6 criteria rows.""" + client = app.test_client() + body = client.get('/').data.decode('utf-8') + # Column headers + for col in ['DictIA', 'MS Teams Premium', 'Otter.ai Business', 'Whisper local']: + assert col in body, f"Comparatif missing column: {col}" + # 6 criteria (extract by their distinctive phrasing) + criteria_keywords = [ + 'Conforme Loi', # row 1 + 'Cloud Act', # row 2 + 'Large-v3 fine-tun', # row 3 (escaped or raw) + 'Diarisation', # row 4 + 'utilisateur/mois', # row 5 + 'Audit trail' # row 6 + ] + for kw in criteria_keywords: + assert kw in body, f"Comparatif missing criterion containing: {kw}" + + +def test_comparatif_uses_responsive_overflow_scroll(): + """Comparatif table wraps in overflow-x-auto for narrow viewports + has accessible caption.""" + client = app.test_client() + body = client.get('/').data.decode('utf-8') + assert 'overflow-x-auto' in body + # Caption is sr-only but mandatory for table accessibility + assert '' in body + # Scope attributes on column and row headers + assert 'scope="col"' in body + assert 'scope="row"' in body + + +def test_conformite_section_present(): + """Conformité forteresse section is present with 4 pillar cards.""" + client = app.test_client() + body = client.get('/').data.decode('utf-8') + assert 'conformite-title' in body + assert 'CONFORMIT' in body and 'FORTERESSE' in body + # Soft hedge: "conçue avec" (not "certifiée par") + assert 'conçue avec' in body or 'conçue avec' in body, \ + "Must use soft hedge 'conçue avec' (LPC art. 219)" + + +def test_conformite_4_pillars(): + """Conformité has 4 pillars: hébergement, Loi 25, Cadre IA, AGPL.""" + client = app.test_client() + body = client.get('/').data.decode('utf-8') + pillar_keywords = [ + 'OVH Beauharnois', # pillar 1 + 'LPRPSP', # pillar 2 (Loi 25 reference) + 'LGGRI', # pillar 3 (Cadre IA reference) + 'AGPL' # pillar 4 + ] + for kw in pillar_keywords: + assert kw in body, f"Conformité missing pillar reference: {kw}" + # Soft hedges (LPC art. 219) + assert 'Mapp' in body, "Must use 'Mappé' (not 'Certifié')" + # Citation/contact for verification + assert 'info@dictia.ca' in body + + +def test_conformite_uses_wcag_safe_text_on_dark(): + """Conformité card text uses text-white/80 minimum on bg-brand-navy.""" + client = app.test_client() + body = client.get('/').data.decode('utf-8') + # Within the conformite section block specifically + section_start = body.find('id="conformite-title"') + # Find next + section_end = body.find('', section_start) + section_html = body[section_start:section_end] + assert 'text-white/80' in section_html or 'text-white/70' in section_html, \ + "Conformité must use /70+ on dark for WCAG AA" + + +def test_no_unverifiable_competitor_claims(): + """Comparatif must NOT contain unhedged percentage claims about competitors (LPC art. 219).""" + client = app.test_client() + body = client.get('/').data.decode('utf-8') + # Forbidden patterns: bold quantitative claims like '5 stars', '100% accurate', 'X% precision' + # We allow our own '95%+' (already hedged with methodology footnote elsewhere) + forbidden_phrases = [ + 'Otter.ai a 100', # No claims about Otter accuracy + 'Teams a 99', # No claims about Teams accuracy + '50% moins cher', # No comparative pricing without verification + ] + for phrase in forbidden_phrases: + assert phrase not in body, f"Forbidden competitive claim: {phrase}"