import { css, html, nothing, type TemplateResult } from 'lit'; import { property } from 'lit/decorators.js'; import { ifDefined } from 'lit/directives/if-defined.js'; import { RoxyDataElement } from '../utils/base-element.js'; import { baseStyles } from '../utils/base-styles.js'; import { customElement } from '../utils/define.js'; import { formatDateTime, formatNumber } from '../utils/format.js'; import { displayField, foldLocalized } from '../utils/localized.js'; import { stackedTableStyles } from '../utils/stacked-table.js'; import { humanize } from '../utils/string.js'; type Json = string | number | boolean | null | Json[] | { [key: string]: Json }; type Scalar = string | number | boolean; const TITLE_KEYS = ['title', 'name', 'label', 'heading', 'overview', 'summary']; /** A title is a label, not a paragraph. `overview`/`summary` are only title candidates when a response carries no real name, and a whole paragraph promoted to an

is worse than no title at all. */ const MAX_TITLE_LEN = 80; const MAX_COMPACT_CELL_LENGTH = 80; const MAX_FLEX_TABLE_COLUMNS = 3; const IMAGE_KEYS = ['imageUrl', 'image', 'icon', 'symbol']; const SKIP_KEYS = ['imageUrl', 'image']; // rendered separately, not in body rows const QUOTE_KEYS = ['affirmation', 'mantra', 'motto', 'quote']; /** * Response fields that are engineer-facing noise on a reading card, and the rules for hiding them. * * @remarks * This mirrors `SUPPRESS_*` in the WordPress plugin's `src/Support/GenericRenderer.php`. Both render the SAME API responses for the same visitor: `roxy-data` when JavaScript runs, the PHP renderer when it does not. If the two disagree the page visibly changes content on hydration, so they must be kept in sync. * * Without this, a real `/numerology/life-path` card printed "Calculation: Month: 6, Day: 15 -> 1+5 = 6, Year: 1990 -> ...", "Type: Single", and a list endpoint printed "Total: 78, Limit: 3, Offset: 0". That is how the number was derived and how the API paginates: it is not the reading the visitor asked for. */ const SUPPRESS_ALWAYS = new Set(['seed']); /** Suppressed only when the object carries a title: an untitled record still needs its identifier. */ const SUPPRESS_NAMED = new Set(['id', 'slug', 'key']); const SUPPRESS_NOISE = new Set([ 'calculation', 'calculations', 'type', // schema discriminator ("single", "general"): indexes a polymorphic shape, means nothing on a card 'position', // pinnacle/challenge index: already carried by the row order 'count', 'totalcount', 'total', 'limit', 'offset', 'page', 'pagesize', 'perpage', ]); /** Sections with more keys than this collapse into `
`. */ const DETAILS_KEYS = 8; /** Depth at which a section folds shut by default rather than rendering open. */ const DEEP_DEPTH = 1; const SCROLL_ROWS = 12; const normKey = (k: string): string => k.toLowerCase().replace(/[_-]/g, ''); /** * `hasKarmicDebt` / `is_master`: a true reads better as a badge than as the row "Has karmic debt: Yes". * * @remarks * Presentation only. The PHP twin also DROPS the false case as "silence, not data", and this deliberately does not follow it there: a generic renderer must never lose a fact the response carries, and `isRetrograde: false` (a planet in direct motion) is a real reading, not an absence. Suppression is reserved for fields that are provably not part of the reading at all, which is what {@link SUPPRESS_NOISE} enumerates. * * Matching on the camel boundary rejects `island` / `issue` / `history`, which a bare `startsWith('is')` would swallow. The PHP twin tests `strpos($key, 'has_')` against a camelCase key, so its badge branch never actually fires; it hard-suppresses the three keys it knew about instead. */ const isBadgeKey = (k: string): boolean => /^(has|is)([A-Z_]|$)/.test(k); const ISO_DATE = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:\d{2})?)?$/; const ENUM_STRING = /^[A-Z0-9]+(_[A-Z0-9]+)+$/; const LINK_STRING = /^https?:\/\//; // Hard cap on recursion. Real RoxyAPI responses nest at most 5-6 deep; anything // deeper is either a circular reference (which would otherwise infinite-loop) // or a payload too rich for the generic fallback to render usefully. The // recursion is otherwise safe: is registered globally by its // `@customElement` decorator on import, so the nested template resolves to // this same class without a separate import. const MAX_DEPTH = 6; /** A whole number under a key whose final word is year (`year`, `birthYear`, `start_year`) is a calendar year, so it never gets digit grouping. */ function isYearField(key: string, value: number): boolean { if (!Number.isInteger(value)) return false; const words = key.replace(/([a-z])([A-Z])/g, '$1 $2').split(/[\s_-]+/); return words[words.length - 1].toLowerCase() === 'year'; } function isPrimitive(value: Json | undefined): value is Scalar | null { return ( value === null || ['string', 'number', 'boolean'].includes(typeof value) ); } /** Object or object-bearing array: needs full width, never fits a dl value cell. */ function isComplex(value: Json): boolean { return ( value !== null && typeof value === 'object' && !(Array.isArray(value) && value.every(isPrimitive)) ); } /** * True when a value would render as nothing: `[]`, `{}`, or an object whose every value is itself empty. * * @remarks * An empty object still counts as {@link isComplex}, so it is promoted to a full-width section and draws a heading over a blank body. This is the generic fallback renderer for endpoints with no bespoke component, so it receives whatever shape the API returns and a heading with nothing under it is the most visible way it can look broken. * * Recursive because emptiness nests: `{ breakdown: { western: [], vedic: [] } }` has keys at the top level and still renders as nothing. */ function isEmptyValue(value: Json): boolean { if (value === null || value === undefined) return true; if (Array.isArray(value)) return value.length === 0; if (typeof value !== 'object') return false; const values = Object.values(value as Record); return values.length === 0 || values.every(isEmptyValue); } /** * Generic fallback heuristic order: * 1. Primitives render as locale-aware text; primitive arrays render as chips. * 2. Compact object arrays render as tables; arrays with many columns plus paragraph text or nested data render as readable records. * 3. Objects with a title render a heading and scalar facts; other objects render a definition list. * 4. Nested objects and object arrays occupy full-width sections, retaining the shared depth and disclosure policy. * Scalar dates, numbers and enums use shared locale-aware formatters; chrome uses t(), response labels use displayField(), and values are folded before classification. * Folding localized values without translating surrounding chrome gives the reader mixed languages, and the server fallback must apply the same field selection and collection presentation. */ @customElement('roxy-data') export class RoxyData extends RoxyDataElement { static styles = [ baseStyles, css` .roxy-card { background: var(--roxy-surface, #fff); border: 1px solid var(--roxy-border, #e4e4e7); border-radius: var(--roxy-radius-md, 8px); padding: var(--roxy-space-md, 1rem); box-shadow: var(--roxy-shadow-sm); } .roxy-card a { color: var(--roxy-accent-ink, #b45309); } .roxy-title { font-size: var(--roxy-text-lg, 1.125rem); font-weight: var(--roxy-weight-bold, 600); margin: 0 0 var(--roxy-space-sm, 0.5rem) 0; color: var(--roxy-primary, #0f172a); letter-spacing: var(--roxy-tracking-tight); } .roxy-summary { color: var(--roxy-secondary, #475569); margin: 0 0 var(--roxy-space-md, 1rem) 0; font-size: var(--roxy-text-sm, 0.875rem); } dl.roxy-rows { margin: 0; display: grid; /* The label column is capped at 25% (the library-wide ceiling every * label-plus-value row holds to, utils/base-styles.ts --roxy-label-col). A * bare max-content lets ONE long key ("Additional Insights") set the width * for every row and starve the values, and a bare 1fr floors at min-content * so a long value cannot shrink. */ grid-template-columns: minmax(8ch, min(25%, max-content)) minmax(0, 1fr); gap: var(--roxy-space-xs, 0.25rem) var(--roxy-space-md, 1rem); } dl.roxy-rows dt { color: var(--roxy-muted, #71717a); font-size: var(--roxy-text-sm, 0.875rem); text-transform: capitalize; } dl.roxy-rows dd { margin: 0; color: var(--roxy-fg, #0a0a0a); font-size: var(--roxy-text-sm, 0.875rem); word-break: break-word; } ul.roxy-chips { display: flex; flex-wrap: wrap; gap: var(--roxy-space-xs, 0.25rem); padding: 0; margin: 0; list-style: none; } ul.roxy-chips li { background: color-mix(in srgb, var(--roxy-accent, #f59e0b) 14%, transparent); color: var(--roxy-fg, #0a0a0a); padding: 2px 8px; border-radius: var(--roxy-radius-full, 9999px); font-size: var(--roxy-text-xs, 0.75rem); } .roxy-collection { min-width: 0; } .roxy-collection.compact { overflow-x: auto; } .roxy-collection.long { max-block-size: 32rem; } .roxy-collection:focus-visible { outline: 2px solid var(--roxy-ring, rgba(245, 158, 11, 0.4)); outline-offset: 2px; } .roxy-record + .roxy-record { margin-block-start: var(--roxy-space-sm, 0.5rem); } table.roxy-table { width: 100%; border-collapse: collapse; font-size: var(--roxy-text-sm, 0.875rem); } table.roxy-table caption { text-align: start; } table.roxy-table th, table.roxy-table td { border-bottom: 1px solid var(--roxy-border, #e4e4e7); padding: var(--roxy-space-sm, 0.5rem); text-align: left; vertical-align: top; text-transform: none; word-break: normal; overflow-wrap: normal; } table.roxy-table th { color: var(--roxy-muted, #71717a); font-weight: var(--roxy-weight-bold, 600); text-transform: capitalize; font-size: var(--roxy-text-xs, 0.75rem); letter-spacing: 0.04em; white-space: nowrap; } /* A 1% auto-table width keeps short columns at their content width, leaving room for prose and nested records. */ :where(table.roxy-table) :is(th, td).col-tight { width: 1%; white-space: nowrap; } /* Text and nested data keep a readable measure beside compact scalars. */ :where(table.roxy-table) :is(th, td).col-wide { width: auto; } :where(table.roxy-table.prose) :is(th, td).col-wide { min-width: 40ch; } .roxy-image { max-width: 100%; height: auto; border-radius: var(--roxy-radius-md, 8px); margin-bottom: var(--roxy-space-md, 1rem); } .roxy-section { margin-top: var(--roxy-space-md, 1rem); } .roxy-section h4 { font-size: var(--roxy-text-sm, 0.875rem); font-weight: var(--roxy-weight-bold, 600); color: var(--roxy-secondary, #475569); margin: 0 0 var(--roxy-space-xs, 0.25rem) 0; text-transform: capitalize; } blockquote.roxy-quote { margin: var(--roxy-space-md, 1rem) 0; padding: var(--roxy-space-sm, 0.5rem) var(--roxy-space-md, 1rem); border-left: 3px solid var(--roxy-accent, #f59e0b); background: color-mix(in srgb, var(--roxy-accent, #f59e0b) 8%, transparent); color: var(--roxy-fg, #0a0a0a); font-size: var(--roxy-text-sm, 0.875rem); font-style: italic; } /* --roxy-fg on the tint, never --roxy-muted: muted on a tinted * color-mix measures 4.24:1 and fails WCAG AA. The tint carries the * accent, the text stays high-contrast. */ .roxy-badge { display: inline-block; padding: 1px 8px; border-radius: var(--roxy-radius-full, 9999px); background: color-mix(in srgb, var(--roxy-accent, #f59e0b) 18%, transparent); color: var(--roxy-fg, #0a0a0a); font-size: var(--roxy-text-xs, 0.75rem); font-weight: var(--roxy-weight-bold, 600); } details.roxy-section { margin-top: var(--roxy-space-md, 1rem); } details.roxy-section > summary { cursor: pointer; font-size: var(--roxy-text-sm, 0.875rem); font-weight: var(--roxy-weight-bold, 600); color: var(--roxy-secondary, #475569); text-transform: capitalize; } details.roxy-section > summary:focus-visible { outline: 2px solid var(--roxy-ring, rgba(245, 158, 11, 0.4)); outline-offset: 2px; border-radius: 4px; } `, stackedTableStyles, ]; /** * Internal recursion depth. Nested instances inherit this from the parent and increment to guard against circular references in the input. Not part of the public API; do not set from consumer code. */ @property({ attribute: false }) depth = 0; protected renderData(data: Json) { if (this.depth >= MAX_DEPTH) { return html`
${this.t('Nested data omitted')}
`; } return html`
${this.renderValue(data)}
`; } private renderValue(value: Json): TemplateResult | typeof nothing { if (value === null || value === undefined) return nothing; if (isPrimitive(value)) return html`

${this.scalarTemplate(value)}

`; if (Array.isArray(value)) return this.renderArray(value); return this.renderObject(value as Record); } private renderArray(arr: Json[]): TemplateResult { if (arr.length === 0) { return html`
${this.t('Empty list')}
`; } if (arr.every(isPrimitive)) { return this.renderChips(arr as (Scalar | null)[]); } const allObjects = arr.every( (v) => v !== null && typeof v === 'object' && !Array.isArray(v), ); if (allObjects) return this.renderObjectArray(arr as Record[]); return html`
    ${arr.map((v) => html`
  1. ${this.renderValue(v)}
  2. `)}
`; } private renderChips(arr: (Scalar | null)[]): TemplateResult { return html`
    ${arr .filter((v): v is Scalar => v !== null) .map((v) => html`
  • ${this.formatScalar(v)}
  • `)}
`; } private renderObjectArray(rows: Record[]): TemplateResult { const clean = rows.map((r) => this.suppress(r)); const keys = this.collectKeys(clean); const prose = new Set( keys.filter((k) => clean.some( (r) => typeof r[k] === 'string' && Array.from(r[k]).length > MAX_COMPACT_CELL_LENGTH, ), ), ); const wide = new Set( keys.filter( (k) => prose.has(k) || clean.some((r) => isComplex(r[k] ?? null)), ), ); const sized = prose.size > 0 || (wide.size > 0 && wide.size < keys.length); const colClass = (k: string) => sized ? (wide.has(k) ? 'col-wide' : 'col-tight') : ''; const records = wide.size > 0 && keys.length > MAX_FLEX_TABLE_COLUMNS; const long = clean.length > SCROLL_ROWS; const bounded = long && !records; return html`
${ records ? html` ${long ? html`

${this.t('{{count}} rows', { count: clean.length })}

` : nothing} ${clean.map((row) => html`
${this.renderField(row)}
`)} ` : html` ${long ? html`` : nothing} ${keys.map( (k) => html``, )} ${clean.map( (row) => html` ${keys.map( (k) => html``, )} `, )}
${this.t('{{count}} rows', { count: clean.length })}
${displayField(this.effectiveLang(), k)}
${this.renderCell(row[k], k)}
` }
`; } /** * Drop the fields that are noise on a reading card, and fold each localized twin into the field it translates. See {@link SUPPRESS_NOISE} and {@link foldLocalized}. * * @remarks * The single funnel for BOTH render paths, the table and the object, which is why the fold belongs here: it is one call and no column-building or row-building code has to know the convention exists. Folding runs FIRST, so a field this then suppresses takes its translation with it rather than leaving `Type Localized` standing where `Type` was hidden. */ private suppress(obj: Record): Record { const folded = foldLocalized(obj); const titled = TITLE_KEYS.some((k) => typeof folded[k] === 'string'); return Object.fromEntries( Object.entries(folded).filter(([k]) => { const n = normKey(k); if (SUPPRESS_ALWAYS.has(n)) return false; if (titled && SUPPRESS_NAMED.has(n)) return false; return !SUPPRESS_NOISE.has(n); }), ); } private renderObject(input: Record): TemplateResult { const obj = this.suppress(input); const titleKey = TITLE_KEYS.find( (k) => typeof obj[k] === 'string' && (obj[k] as string).length <= MAX_TITLE_LEN, ); const imageKey = IMAGE_KEYS.find( (k) => typeof obj[k] === 'string' && (obj[k] as string).startsWith('http'), ); const summaryKey = titleKey !== 'summary' && typeof obj.summary === 'string' ? 'summary' : null; const quoteKey = QUOTE_KEYS.find((k) => typeof obj[k] === 'string'); const entries = Object.entries(obj).filter( ([k, v]) => k !== titleKey && k !== summaryKey && k !== quoteKey && !SKIP_KEYS.includes(k) && !isEmptyValue(v), ); // Scalars and primitive arrays fit the two-column rows; objects and // object arrays promote to full-width sections so nested tables are // not squeezed into the value column. const rows = entries.filter(([, v]) => !isComplex(v)); const sections = entries.filter(([, v]) => isComplex(v)); return html` ${ imageKey ? html`${titleKey` : nothing } ${titleKey ? html`

${obj[titleKey]}

` : nothing} ${summaryKey ? html`

${obj[summaryKey]}

` : nothing} ${quoteKey ? html`
${obj[quoteKey]}
` : nothing} ${ rows.length > 0 ? html`
${rows.map( ([k, v]) => html`
${displayField(this.effectiveLang(), k)}
${this.renderField(v, k)}
`, )}
` : nothing } ${sections.map(([k, v]) => this.renderSection(k, v))} `; } /** * A named block of nested data. Collapses into `
` once it carries more than {@link DETAILS_KEYS} keys, so one fat object (a 27-nakshatra map, a 12-house table) cannot bury the rest of the card under a wall of rows. * * @remarks * Deliberately carries no `part`. The heading here IS a response key, so the only name this block could take is computed, and `roxy-reference-card` is the one component allowed to derive a part at runtime (`catalog.test.ts` pins it as the only one). A bare `part="section"` is the other tempting option and is worse: the vocabulary pairs `section` with a specific name precisely so `::part(section patterns)` addresses ONE block, and a token that lands identically on every key of every response addresses none of them. */ private renderSection(key: string, value: Json): TemplateResult { const size = value !== null && typeof value === 'object' ? Array.isArray(value) ? value.length : Object.keys(value).length : 0; const body = this.renderField(value, key); const heading = displayField(this.effectiveLang(), key); // Past depth 1 a section folds shut by default. A real /numerology/chart response // rendered every level open and grew to a 29,933px-tall element, about thirty // screens of correct but unusable output. The reader opens what they want. if (size > DETAILS_KEYS || this.depth >= DEEP_DEPTH) { return html`
${heading} ${body}
`; } return html`

${heading}

${body}
`; } private renderField(value: Json, key?: string): TemplateResult | string { if (value === null || value === undefined) return ''; if (value === true && key !== undefined && isBadgeKey(key)) { return html`${this.t('Yes')}`; } if (isPrimitive(value)) return html`${this.scalarTemplate(value, key)}`; if (Array.isArray(value) && value.every(isPrimitive)) { return this.renderChips(value as (Scalar | null)[]); } // Bare nested cards suppress their surface and shadows while retaining padding and a hairline border. return html``; } private renderCell( value: Json | undefined, key?: string, ): TemplateResult | string { if (value === null || value === undefined) return ''; if (value === true && key !== undefined && isBadgeKey(key)) { return html`${this.t('Yes')}`; } if (isPrimitive(value)) return this.scalarTemplate(value, key); if (Array.isArray(value) && value.every(isPrimitive)) { return (value as (Scalar | null)[]) .filter((v): v is Scalar => v !== null) .map((v) => this.formatScalar(v)) .join(', '); } // Nested cells recurse rather than stringify, with bare cards retaining padding and a hairline border. return html``; } private scalarTemplate(value: Scalar, key?: string): TemplateResult | string { if (typeof value === 'string' && LINK_STRING.test(value)) { return html`${value}`; } return this.formatScalar(value, key); } private formatScalar(value: Scalar, key?: string): string { if (typeof value === 'number') { if (key !== undefined && isYearField(key, value)) return String(value); return formatNumber(this.effectiveLang(), value, 2) || String(value); } if (typeof value === 'boolean') return value ? this.t('Yes') : this.t('No'); if (ISO_DATE.test(value)) return formatDateTime(this.effectiveLang(), value); if (ENUM_STRING.test(value)) return humanize(value.toLowerCase()); return value; } private collectKeys(rows: Record[]): string[] { const seen = new Set(); for (const row of rows) { for (const k of Object.keys(row)) seen.add(k); } return Array.from(seen); } } declare global { interface HTMLElementTagNameMap { 'roxy-data': RoxyData; } }