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`- ${this.renderValue(v)}
`)}
`;
}
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`${this.t('{{count}} rows', { count: clean.length })}` : nothing}
${keys.map(
(k) =>
html`| ${displayField(this.effectiveLang(), k)} | `,
)}
${clean.map(
(row) => html`
${keys.map(
(k) =>
html`|
${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`
`
: 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;
}
}