feat(web): polish marketing site — SVGs, sticky pricing, language switcher#1673
Conversation
…tcher
Marketing site refresh that touches several adjacent surfaces.
Web — visuals
- Migrate 16 SVG mockups from the old Framer site into public/marketing/svg
with descriptive names (mock-chat, mock-conversations, mock-workflow-grid,
mock-approvals-table, mock-integrations-stack, mock-compliance-columns,
mock-blocks, mock-product-card, mock-document-card, mock-document-long,
…) and wire them into feature-secure, feature-sectors, compliance-trust,
feature-grid (security-2/3) replacing the corresponding PNGs.
- Replace lucide icons in feature-secure / feature-grid / compliance-trust
with stroke-based marketing icons matching the old site (chat,
conversations, workflows, approvals, hospitality, legal, finance,
independent, stack, secure, built-for-you, transparent, certified,
chevron-down, language).
Web — pricing & hardware pricing
- Sticky-only-on-tier-cards comparison header: each Community/Pro/
Enterprise (and Quality/Hybrid/Speed) cell sticks individually at
top-16 with bg-bg-base/60 + backdrop-blur-lg + 1px inset shadow border
(regular borders fold under border-collapse on sticky cells). The
leftmost label scrolls naturally with the body.
- Tier-card vertical alignment: always reserve the price-suffix line
(min-h on the empty span) and apply min-h-[4.5em] to the tagline so
"Plan includes" / metrics borders line up across all three cards.
- Pricing tier feature checks now use text-emerald-600 to match the
comparison-table check; "Cloud: ✓" inlined onto a single line, with a
shared InlineCheck rendering the unicode marker as the same lucide
Check glyph.
- GDPR removed from the Community tier (genuinely not compliant) and
from Enterprise (already inherited via "Everything in Pro"); PII-
redaction row added to the comparison table.
- Hardware buy prices corrected (31.7k / 32.9k / 35.3k) and "+ VAT one-
off" suffix simplified to "+ VAT" — the rental side already reads
"/month" so the disambiguation is preserved.
- "Recommended AI model" → "SoA AI model" across en/de/fr.
- Fixed duplicated "See our hardware pricing." in the comparison-table
extras span.
Web — shared components
- New TierCard (services/web/app/components/blocks/tier-card.tsx) wraps
the article + animation + header/price/tagline so PricingTiers and
HardwareTiers share the same vertical rhythm.
- New CompareTable (services/web/app/components/blocks/compare-table.tsx)
renders the wrapping motion.div + table + sticky tier headers and
drives both PricingCompare and HardwareCompare from `tiers[]` +
rows[] (data | section | span). Includes a proper sr-only <caption>.
- New ProgressBar (services/web/app/components/progress-bar.tsx) with
role="progressbar" + clamped value, used by HardwareTiers for the
quality/speed/storage metric bars.
- Custom-dropdown LanguageSwitcher with country flags (FlagEN/FlagDE/
FlagFR) and locale-aware region resolution: pick "Deutsch" and the
switcher routes to de-CH on a Swiss browser, falling back to de
otherwise. Strings live in messages/global.json so they're shared.
i18n tests
- Extract messages-parity and orphan-key tests into a reusable
@tale/ui/i18n-tests entry point exposing defineMessagesParityTests
and defineMessagesUsageTests. Vitest declared as an optional peer
dep so consumers that don't use the entry don't need it installed.
- Refactor services/platform and services/web tests into thin wrappers
that call those factories. Add web's keys-dynamic.txt allowlist
documenting the legacy keys awaiting wire-up vs cleanup.
- Update the plop react-service template + generator so newly
scaffolded services include both tests, the keys-dynamic.txt stub,
and the previously-unregistered config.ts / types.ts.
- Usage scanner picks up the new t(`${var}.suffix`) template pattern
used by pricing/hardware tier cards (in addition to the existing
`prefix.${var}` pattern), so dynamically-built tier keys resolve
without needing allowlist entries.
Pre-PR checklist
- [x] Ran `bun run check` (format, lint, typecheck, all tests).
- [x] Updated services/platform/messages — N/A (changes scoped to web
and shared @tale/ui).
- [x] Updated docs — N/A (no user-facing API/CLI surface changed).
- [x] Ran `bun run --filter @tale/docs lint` — N/A.
- [x] Updated README.md / README.de.md / README.fr.md — N/A.
📝 WalkthroughWalkthroughThis PR introduces a reusable i18n testing library in Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 8
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
services/web/messages/de.json (1)
257-262:⚠️ Potential issue | 🟡 Minor | ⚡ Quick winHour-unit inconsistency in German copy:
/ hvs/ Std.
maintenanceRate,customDevelopmentRate, andconsultingRateuse the German abbreviationStd., but the newproMaintenanceline uses/ h. Pick one (preferablyStd.to match the rest of the German pricing strings) so users see consistent unit labels in the comparison table.🌐 Proposed fix
- "proMaintenance": "Cloud: ✓\nSelbst gehostet: CHF 80–120 / h" + "proMaintenance": "Cloud: ✓\nSelbst gehostet: CHF 80–120 / Std."🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@services/web/messages/de.json` around lines 257 - 262, The German pricing strings are inconsistent: the keys maintenanceRate, customDevelopmentRate, and consultingRate use "Std." while proMaintenance uses "/ h"; update the value for the proMaintenance key to use "Std." (e.g., "Cloud: ✓\nSelbst gehostet: CHF 80–120 / Std.") so the hour-unit matches the other keys and keeps the comparison table consistent.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@packages/ui/src/i18n-tests/parity.ts`:
- Around line 102-108: The test name for the it(...) block is ambiguous; update
the description string in the test that uses locale, baseLocale, keys and
baseKeys (the it(...) asserting extra = [...keys].filter(k => !baseKeys.has(k))
and expect(extra).toEqual([])) to clearly state it checks for extra keys not
present in the base locale — e.g., replace "has no keys missing from
${baseLocale}.json" with "has no extra keys not present in ${baseLocale}.json"
or "has no keys absent from ${baseLocale}.json" so CI output reads
unambiguously.
- Around line 91-122: Replace the Vitest-compatible describe.each calls with the
recommended describe.for so each locale string is passed as a single argument
(not spread) and you gain TestContext; update both occurrences where
describe.each(primary) and describe.each(regional) are used (inside the parity
test block that computes keys via flatten(loadLocale(messagesDir, locale))) to
describe.for(primary) and describe.for(regional) respectively, keeping the
existing callback signature (locale) => { ... } and preserving the tests that
compute keys and assert against baseKeys.
In `@services/web/app/components/blocks/feature-secure.tsx`:
- Around line 99-107: Replace the raw <img> with the shared Image component:
import Image from '@/components/ui/image', remove the bare <img> inside the div
with className={panelStage}, and render <Image src={item.illustration} ...
className={panelImage} /> preserving the original attributes (alt value or
aria-hidden, draggable, loading="lazy") and any className usage; ensure you
reference item.illustration and panelImage so the visual layout and
accessibility flags remain the same.
In `@services/web/app/components/blocks/pricing-compare.tsx`:
- Around line 47-55: Add role="img" to the SVG Check usages to meet WCAG AA:
update the InlineCheck component (function InlineCheck) to pass role="img" into
the Check element alongside the existing aria-label, and likewise update the
Check rendered in renderCell (the Check at line ~71) to include role="img" while
keeping its aria-label and strokeWidth/className props intact so screen readers
treat the SVGs as images.
In `@services/web/app/components/blocks/tier-card.tsx`:
- Around line 97-99: Remove the non-breaking-space fallback in the span inside
the TierCard component: replace the ternary "{priceSuffix ? priceSuffix : ' '}"
with just "{priceSuffix}" (or "{priceSuffix ?? null}") so the min-h-[1.25rem]
handles spacing and you don't accidentally render a stray NBSP when callers pass
an empty string; update the span element that references priceSuffix in
services/web/app/components/blocks/tier-card.tsx accordingly.
In `@services/web/app/components/layout/language-switcher.tsx`:
- Around line 131-175: This code uses ARIA menu roles (role="menu" on the ul and
role="menuitemradio" + aria-checked on each button) without implementing menu
keyboard behavior; remove the misleading menu semantics and rely on native
button/list accessibility: delete role="menu" and role="menuitemradio" and
remove aria-checked, keep the UL as a plain list and the items as buttons, and
mark the active locale with aria-current="true" (use currentBase to set
aria-current on the button rendered inside the BASE_LOCALES.map where
handleSelect, LOCALE_FLAGS and t are used) so keyboard users still get clear
state and buttons remain natively keyboard-focusable.
In `@services/web/app/components/progress-bar.tsx`:
- Around line 11-15: The JSDoc and API are wrong: aria-labelledby must be
applied on the element with role="progressbar" and there is no way to pass it
currently. Update the ProgressBar component by correcting the JSDoc comment for
ariaLabel to state that consumers may alternatively use aria-labelledby on the
progressbar itself, add an optional ariaLabelledBy?: string prop to the
component's props (alongside ariaLabel), and ensure the rendered element with
role="progressbar" uses aria-labelledby={ariaLabelledBy} when provided (falling
back to aria-label={ariaLabel} if ariaLabelledBy is undefined). Also update any
prop-type/TS types and the JSDoc to reflect the new prop and correct guidance.
In `@services/web/messages/global.json`:
- Around line 1-10: The shared key languageSwitcher.ariaLabel in
services/web/messages/global.json causes t('languageSwitcher.ariaLabel') in
language-switcher.tsx to always announce English; remove ariaLabel from
global.json and add a languageSwitcher.ariaLabel entry to each per-locale file
(services/web/messages/en.json, de.json, fr.json) with the proper localized
strings (e.g., "English": "Language", "de": "Sprache", "fr": "Langue") so the
calls in language-switcher.tsx (refs: languageSwitcher.ariaLabel usage at lines
~109 and ~134) resolve to the correct locale-specific label. Ensure keys'
nesting matches existing languageSwitcher structure in the per-locale files.
---
Outside diff comments:
In `@services/web/messages/de.json`:
- Around line 257-262: The German pricing strings are inconsistent: the keys
maintenanceRate, customDevelopmentRate, and consultingRate use "Std." while
proMaintenance uses "/ h"; update the value for the proMaintenance key to use
"Std." (e.g., "Cloud: ✓\nSelbst gehostet: CHF 80–120 / Std.") so the hour-unit
matches the other keys and keeps the comparison table consistent.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro
Run ID: 6d855452-5895-4b77-9453-5e4962cf50f8
⛔ Files ignored due to path filters (16)
services/web/public/marketing/svg/mock-approvals-table.svgis excluded by!**/*.svgservices/web/public/marketing/svg/mock-automation-flow.svgis excluded by!**/*.svgservices/web/public/marketing/svg/mock-blocks.svgis excluded by!**/*.svgservices/web/public/marketing/svg/mock-card-stack.svgis excluded by!**/*.svgservices/web/public/marketing/svg/mock-chat.svgis excluded by!**/*.svgservices/web/public/marketing/svg/mock-compliance-columns-alt.svgis excluded by!**/*.svgservices/web/public/marketing/svg/mock-compliance-columns.svgis excluded by!**/*.svgservices/web/public/marketing/svg/mock-conversations.svgis excluded by!**/*.svgservices/web/public/marketing/svg/mock-doc-summarizing-alt.svgis excluded by!**/*.svgservices/web/public/marketing/svg/mock-doc-summarizing.svgis excluded by!**/*.svgservices/web/public/marketing/svg/mock-document-card.svgis excluded by!**/*.svgservices/web/public/marketing/svg/mock-document-long.svgis excluded by!**/*.svgservices/web/public/marketing/svg/mock-integrations-stack-alt.svgis excluded by!**/*.svgservices/web/public/marketing/svg/mock-integrations-stack.svgis excluded by!**/*.svgservices/web/public/marketing/svg/mock-product-card.svgis excluded by!**/*.svgservices/web/public/marketing/svg/mock-workflow-grid.svgis excluded by!**/*.svg
📒 Files selected for processing (32)
packages/ui/package.jsonpackages/ui/src/i18n-tests/index.tspackages/ui/src/i18n-tests/parity.tspackages/ui/src/i18n-tests/usage.tsservices/platform/lib/i18n/messages-usage.test.tsservices/platform/lib/i18n/messages.test.tsservices/web/app/components/blocks/compare-table.tsxservices/web/app/components/blocks/compliance-trust.tsxservices/web/app/components/blocks/feature-grid.tsxservices/web/app/components/blocks/feature-sectors.tsxservices/web/app/components/blocks/feature-secure.tsxservices/web/app/components/blocks/hardware-compare.tsxservices/web/app/components/blocks/hardware-tiers.tsxservices/web/app/components/blocks/pricing-compare.tsxservices/web/app/components/blocks/pricing-tiers.tsxservices/web/app/components/blocks/tier-card.tsxservices/web/app/components/icons/marketing-icons.tsxservices/web/app/components/layout/language-switcher.tsxservices/web/app/components/layout/site-footer.tsxservices/web/app/components/progress-bar.tsxservices/web/app/routes/index.tsxservices/web/lib/i18n/keys-dynamic.txtservices/web/lib/i18n/messages-usage.test.tsservices/web/lib/i18n/messages.test.tsservices/web/messages/de.jsonservices/web/messages/en.jsonservices/web/messages/fr.jsonservices/web/messages/global.jsontools/plop/generators/react-service.tstools/plop/templates/react-service/lib/i18n/keys-dynamic.txttools/plop/templates/react-service/lib/i18n/messages-usage.test.ts.hbstools/plop/templates/react-service/lib/i18n/messages.test.ts
…w fixes
- Pricing page: Community + Enterprise tiers with region (CH/EUR) and billing
(monthly/yearly) toggles, shared via `pricing-section` and `lib/pricing/region`
- Compare table now reads region-aware values; translations updated across
en/de/fr to match the new structure
- Accessibility/i18n review fixes:
- parity test renamed for clarity and switched from `describe.each` to
`describe.for`
- `role="img"` on lucide `<Check>` next to its aria-label
- language switcher: drop misleading menu/menuitemradio semantics, use
`aria-current` on the active locale
- `ProgressBar`: add `ariaLabelledBy` prop, fix JSDoc
- `tier-card`: drop NBSP fallback under price suffix
- `i18n.ts`: one-namespace-deep merge so per-locale `languageSwitcher.ariaLabel`
coexists with shared `languageSwitcher.locales` in `global.json`
Vitest is declared as an optional peerDependency on @tale/ui (consumers of @tale/ui/i18n-tests need it in scope) and also imported by the package's own tests. Knip flagged the internal usage; ignore it for this workspace.
Summary
Marketing-site refresh on
services/webplus a small bit of shared infrastructure that came along for the ride.Web — visuals
FlagEN/FlagDE/FlagFRfor the language switcher.Web — pricing & hardware-pricing
<th>sticks individually attop-16withbg-bg-base/60+backdrop-blur-lg+ a 1px inset shadow border. Realborderrules fold underborder-collapse: collapseon sticky cells, hence the shadow trick. The leftmost label scrolls naturally so there's no big sticky blank box on the left.min-hon the empty span) and applymin-h-[4.5em]to the tagline so "Le plan inclut :" / metric borders line up across all three cards regardless of how each tagline wraps.text-emerald-600to match the comparison-table check;Cloud: ✓inlined onto a single line with a sharedInlineCheckrendering the unicode marker as the same lucideCheckglyph.+ VAT one-off→+ VAT(rental side already reads/month).Web — shared components
PricingTiersandHardwareTiersconsume it.TierKey, drives both compare pages from atiers[]+rows[](data|section|span) input. Includes the proper<caption>for screen readers.role="progressbar"with clamped value +aria-valuemin/max/now, used by hardware-tiers metric bars.de-CHon a Swiss browser, falling back todeotherwise. Strings live in messages/global.json.i18n tests (shared)
@tale/ui/i18n-testsexposingdefineMessagesParityTestsanddefineMessagesUsageTests.vitestdeclared as an optional peer dep so consumers that don't use the entry don't need it.t(\${var}.suffix`)` (used by tier cards), so dynamically-built tier keys resolve without allowlist entries.keys-dynamic.txtstub, and the previously-unregisteredconfig.ts/types.ts.CodeRabbit findings
Local
coderabbit review --agentreturned 6 trivial findings; applied 3:loadLocaleJSON read with try/catch + contextual error in parity.ts.vitestas an optional peer dep on @tale/ui.<caption>element in compare-table.tsx instead of a span inside the first<th>.Skipped 3:
keys-dynamic.txt.Pre-PR checklist
Test plan
Summary by CodeRabbit
New Features
Bug Fixes & Improvements
Documentation