Skip to content

What is the shape of messages.json, and how is rule 7 enforced? #11

Closed

Description

@Abuubkar

Question

Every user-facing string comes from one English messages.json. Decide:

  • Structure: nested by section and component, or flat keys; how lists (FAQ pairs, specialties, systems, comparison rows, trust points) are represented.
  • Rich text: the hero heading has highlighted spans; how emphasis is encoded without HTML in JSON.
  • Typed access: generated types or as const import, and the accessor components use.
  • Placeholders: how bracketed placeholder values are marked so the client can find them later.
  • Enforcement: which lint rule from the Vite+ tooling flags string literals in JSX outside base/, or whether review is the only guard.

Produce the schema and an excerpt as the resolution.

Activity

  1. self-assigned this
    on Sep 5, 2026
  2. Abuubkar commented on Sep 5, 2026

    @Abuubkar
    OwnerAuthor

    Resolved by grilling. One English messages.json, nested by section to mirror src/sections/, ~200 strings, static import with compiler-checked access.

    Shape

    Each top-level key matches a section directory name, so sections/Faq.tsx reads m.faq and nothing else. List content lives in the same file as arrays of objects — splitting "UI labels" from "marketing copy" would require a per-string judgement that does not survive contact with copy edits.

    type Messages = {
      site:   { name: string; phone: string; phoneHref: string };
      nav:    { label: string; links: { href: string; text: string }[]; cta: string; menuLabel: string };
      hero:   { badge: string; title: Segment[]; lead: string;
                ctaPrimary: string; ctaSecondary: string; trustPoints: string[] };
      problem:  { eyebrow: string; title: string;
                  cards: { stat: string; title: string; blurb: string }[] };
      services: { eyebrow: string; title: string; footnote: string;
                  cards: { eyebrow: string; icon: IconName; title: string; blurb: string; items: string[] }[] };
      how:      { eyebrow: string; title: string; steps: { title: string; blurb: string }[] };
      specialties: { eyebrow: string; title: string; items: string[]; more: string };
      systems:  { title: string; listLabel: string; items: string[];
                  comparison: { caption: string; columns: string[]; rows: string[][] } };
      why:      { eyebrow: string; title: string; points: { title: string; blurb: string }[] };
      fit:      { title: string; goodTitle: string; good: string[]; badTitle: string; bad: string[] };
      results:  { eyebrow: string; title: string; placeholderNotice: string; comingSoon: string;
                  items: { specialty: string; before: string; after: string; metric: string; summary: string }[] };
      faq:      { eyebrow: string; title: string; items: { q: string; a: string }[] };
      assessment: { badge: string; title: string; lead: string; bullets: string[];
                    form: { label: string; fields: Record<FieldName, FieldMessages>;
                            submit: string; disclaimer: string; noscript: string };
                    success: { title: string; body: string } };
      footer:   { tagline: string; addressLines: string[]; legalLinks: { href: string; text: string }[];
                  copyright: string };
    };
    
    type FieldMessages = { label: string; hint?: string; placeholder?: string; options?: string[] };

    Rich text — one string, one mechanism

    The hero heading is the only string in the canvas needing emphasis, and the only one containing a <br>. It is encoded as segments, not HTML:

    type Segment = string | { text: string; tone: 'brand' | 'success' } | { br: true };
    "title": [
      "Collect Every Dollar You've ",
      { "text": "Earned", "tone": "brand" },
      ".",
      { "br": true },
      "Cut Denials ",
      { "text": "Before They Happen", "tone": "success" },
      "."
    ]

    Rendered by a RichText base component that maps tone to a token role and br to <br>. Rejected: splitting into five plain keys, which freezes the emphasis boundaries into JSX and leaves the client unable to read or reword the sentence as a sentence; and an inline [[…]] convention, which collides with the bracket placeholder convention below.

    Excerpt

    {
      "site": { "name": "Execile", "phone": "(555) 000-0000", "phoneHref": "tel:+15550000000" },
    
      "hero": {
        "badge": "Medical billing & RCM for independent practices",
        "title": [ "Collect Every Dollar You've ", { "text": "Earned", "tone": "brand" }, ".",
                   { "br": true },
                   "Cut Denials ", { "text": "Before They Happen", "tone": "success" }, "." ],
        "lead": "Specialty-assigned billing for independent practices with 1–20 providers. We work inside your existing EHR, report what we collect, and quote our price upfront.",
        "ctaPrimary": "Get a Free Billing Assessment",
        "ctaSecondary": "See Our Services",
        "trustPoints": [
          "HIPAA Compliant", "BAA on Day One", "No Setup Fees",
          "No Long-Term Contract", "US-facing team · 24-hour processing"
        ]
      },
    
      "problem": {
        "eyebrow": "The problem",
        "title": "Revenue leaks quietly, one claim at a time.",
        "cards": [
          { "stat": "[X%]",   "title": "Rising denials",
            "blurb": "Denial rates keep climbing while appeal windows quietly close." },
          { "stat": "[X days]", "title": "Aged AR nobody works",
            "blurb": "Claims past 90 days sit untouched until they time out." },
          { "stat": "[X%]",   "title": "No revenue visibility",
            "blurb": "You learn about collection problems weeks after they happen." },
          { "stat": "[X wks]", "title": "Providers not enrolled",
            "blurb": "New hires see patients before payers will pay for them." }
        ]
      },
    
      "faq": {
        "eyebrow": "FAQ",
        "title": "Common questions",
        "items": [
          { "q": "Do you work inside our existing EHR?",
            "a": "Yes. We work inside your existing EHR/PM — no software switch and no re-training." }
        ]
      },
    
      "assessment": {
        "badge": "Free · No obligation",
        "title": "Get a Free Billing Assessment",
        "form": {
          "label": "Billing assessment request",
          "fields": {
            "name":      { "label": "Full name" },
            "email":     { "label": "Work email" },
            "specialty": { "label": "Specialty", "placeholder": "Select…",
                           "options": ["[SPECIALTY 1]", "[SPECIALTY 2]", "[SPECIALTY 3]",
                                       "Primary Care", "Behavioral Health", "Physical Therapy",
                                       "Urgent Care", "Internal Medicine", "Pediatrics", "Other"] },
            "message":   { "label": "Message", "hint": "(optional)" }
          },
          "submit": "Get a Free Billing Assessment",
          "disclaimer": "HIPAA-compliant · BAA before any data moves · No obligation.",
          "noscript": "JavaScript is required to send this form. Please call (555) 000-0000 instead."
        },
        "success": {
          "title": "Request received",
          "body": "We'll reach out within one business day to schedule your assessment and send the BAA."
        }
      }
    }

    Typed access

    // src/messages.ts
    import messages from './messages.json';
    export const m = messages;

    Consumers write import { m } from '~/messages' and read m.faq.items[0].q. resolveJsonModule infers the shape, so key paths are compiler-checked with no codegen step and no runtime lookup — it is a static import that prerenders and dead-code-eliminates. Generated types were rejected as a build step reproducing what the compiler already infers; the only loss is literal types (arrays widen to string[]), which costs nothing with no locale layer (rule 13).

    Messages above is written as a hand-maintained type only where it constrains authoring — Segment[] and IconName — applied via satisfies in messages.ts.

    Placeholders

    The canvas's bracket convention is kept verbatim. Every remaining placeholder in the content:

    [X%] · [X days] · [X wks] · [X] · [Y%] · [Y days] · [SPECIALTY 1..3] · [Specialty] · [STARTUP NAME] · [Street address] · [City, ST ZIP] · [domain] · [PLACEHOLDER — replace with real client results] · [One-line summary of what changed and why.]

    A pnpm placeholders script walks the JSON and prints each one with its key path:

    // scripts/placeholders.mjs
    import m from '../src/messages.json' with { type: 'json' };
    const hits = [];
    (function walk(node, path) {
      if (typeof node === 'string') {
        for (const p of node.match(/\[[^\]]+\]/g) ?? []) hits.push([path, p]);
      } else if (node && typeof node === 'object') {
        for (const [k, v] of Object.entries(node)) walk(v, path ? `${path}.${k}` : k);
      }
    })(m, '');
    for (const [path, p] of hits) console.log(`${path.padEnd(44)} ${p}`);
    console.log(`\n${hits.length} placeholder(s) remaining`);
    process.exit(hits.length ? 1 : 0);

    This turns "the client updates the copy at the end" into a checklist that can be verified empty. It exits non-zero while placeholders remain, so it can gate a future production deploy without gating v1 — the standing constraint keeps placeholders in the launch copy deliberately.

    Enforcement

    react/jsx-no-literals applies everywhere, including base/. Base components render {children} and carry no literal text; exempting the directory would open the hole the rule exists to close.

    Its blind spot: the rule catches JSX text children only. aria-label, alt, title and placeholder are user-facing and pass through unflagged. Partial mitigation on the component side — IconButton requires a label prop, Field requires label, Icon is aria-hidden unless given a title — so the common cases are structurally forced through a prop. Attribute strings remain a review matter.

    This is the fourth item in the rule-enforcement gap on #15, joining literal token values, ramp-key leakage, and the style escape hatch.

    Notes for other tickets

    • What is the project scaffold, and what runs on commit? #15: confirm Oxlint ships react/jsx-no-literals; the Vite+ research asserted the mapping but it is unverified against the actual rule set. Add the placeholders script to package scripts.
    • What is the Assessment Form pipeline end to end? #12: form field labels, options, submit, disclaimer, noscript and success copy all come from m.assessment.form / m.assessment.success.
    • Results mode (see CONTEXT.md) is a build-time flag, not a message. messages.json carries both results.items and results.comingSoon; the flag picks which renders.
  3. Abuubkar commented on Sep 5, 2026

    @Abuubkar
    OwnerAuthor

    Resolved. Schema, excerpt, RichText segment encoding, typed access, placeholder script and enforcement in the resolution comment.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

wayfinder:grillingWayfinder ticket — HITL conversation

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions