import { css, html, nothing } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { signGlyph } from '../tokens/index.js'; import type { GetDailyHoroscopeResponse, GetMonthlyHoroscopeResponse, GetWeeklyHoroscopeResponse, } from '../types/index.js'; import { RoxyDataElement } from '../utils/base-element.js'; import { baseStyles } from '../utils/base-styles.js'; import { formatDate } from '../utils/format.js'; type HoroscopeData = | GetDailyHoroscopeResponse | GetWeeklyHoroscopeResponse | GetMonthlyHoroscopeResponse; /** * Daily, weekly, or monthly horoscope card. Pass `data` from * /astrology/horoscope/{sign}/{daily|weekly|monthly}. * * @remarks * A horoscope reads as prose end to end, but it is not one: the card has a real * data spine and `hide-readings` keeps all of it. The sign and its glyph, the * period and date, the energy meter, the sky strip (Moon sign, Moon phase and the * transits this reading was derived from), the lucky number, colour, days and * compatible signs, and the monthly key dates all stay. The overview paragraph, * the love, career, health, finance and advice sections, and the week-by-week * focus and advice go. The two prose blocks each carry their own heading, so each * goes whole rather than leaving one over nothing. */ @customElement('roxy-horoscope-card') export class RoxyHoroscopeCard extends RoxyDataElement { static styles = [ baseStyles, css` .card { background: var(--roxy-surface, #fff); color: var(--roxy-fg, #0a0a0a); border: 1px solid var(--roxy-border, #e4e4e7); border-radius: var(--roxy-radius-md, 8px); padding: var(--roxy-space-lg, 1.5rem); box-shadow: var(--roxy-shadow-sm); display: grid; /* Never an implicit auto column: it floors at min-content, so one long * unbreakable string widens the track past the padded card. */ grid-template-columns: minmax(0, 1fr); gap: var(--roxy-space-md, 1rem); } .head { display: flex; align-items: center; gap: var(--roxy-space-md, 1rem); } .glyph { font-size: 2.25rem; color: var(--roxy-accent-ink, #b45309); line-height: 1; } .title { font-size: var(--roxy-text-xl, 1.5rem); font-weight: var(--roxy-weight-bold, 600); margin: 0; letter-spacing: var(--roxy-tracking-tight); text-transform: capitalize; } .date { font-size: var(--roxy-text-sm, 0.875rem); color: var(--roxy-muted, #71717a); } .energy { margin-left: auto; font-variant-numeric: tabular-nums; font-size: var(--roxy-text-sm, 0.875rem); color: var(--roxy-secondary, #475569); } .energy-bar { display: inline-block; width: 6rem; height: 6px; background: var(--roxy-border, #e4e4e7); border-radius: var(--roxy-radius-full, 9999px); overflow: hidden; margin-left: 6px; vertical-align: middle; } .energy-bar > span { display: block; height: 100%; background: var(--roxy-accent, #f59e0b); transition: width var(--roxy-motion-duration, 200ms) var(--roxy-motion-easing, cubic-bezier(0.4, 0, 0.2, 1)); } .overview { font-size: var(--roxy-text-base, 1rem); color: var(--roxy-fg, #0a0a0a); margin: 0; } .sections { display: grid; grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr)); gap: var(--roxy-space-md, 1rem); } .section h3 { margin: 0 0 var(--roxy-space-xs, 0.25rem) 0; font-size: var(--roxy-text-xs, 0.75rem); color: var(--roxy-muted, #71717a); font-weight: var(--roxy-weight-bold, 600); text-transform: uppercase; letter-spacing: 0.06em; } .section p { margin: 0; font-size: var(--roxy-text-sm, 0.875rem); color: var(--roxy-fg, #0a0a0a); } .lucky { display: flex; flex-wrap: wrap; gap: var(--roxy-space-sm, 0.5rem); border-top: 1px solid var(--roxy-border, #e4e4e7); padding-top: var(--roxy-space-md, 1rem); font-size: var(--roxy-text-sm, 0.875rem); color: var(--roxy-secondary, #475569); } .lucky strong { color: var(--roxy-fg, #0a0a0a); font-weight: var(--roxy-weight-bold, 600); } .compat-wrap { width: 100%; display: flex; align-items: center; flex-wrap: wrap; gap: var(--roxy-space-xs, 0.25rem); } .compat { display: flex; flex-wrap: wrap; gap: var(--roxy-space-xs, 0.25rem); } .compat span { background: color-mix(in srgb, var(--roxy-accent, #f59e0b) 16%, transparent); color: var(--roxy-fg, #0a0a0a); padding: 2px 8px; border-radius: var(--roxy-radius-full, 9999px); font-size: var(--roxy-text-xs, 0.75rem); text-transform: capitalize; } /* The sky strip: the Moon placement and the live transits that produced * this reading. It sits under the overview because it is the evidence * for it, not decoration. */ .sky { display: grid; gap: var(--roxy-space-sm, 0.5rem); border: 1px solid var(--roxy-border, #e4e4e7); border-radius: var(--roxy-radius-md, 8px); padding: var(--roxy-space-sm, 0.5rem) var(--roxy-space-md, 1rem); } .moon-line { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0.35rem var(--roxy-space-md, 1rem); font-size: var(--roxy-text-sm, 0.875rem); color: var(--roxy-fg, #0a0a0a); } .moon-line b { font-weight: var(--roxy-weight-bold, 600); } .moon-line .lbl { color: var(--roxy-muted, #71717a); font-size: var(--roxy-text-xs, 0.75rem); text-transform: uppercase; letter-spacing: 0.06em; font-weight: var(--roxy-weight-bold, 600); margin-right: 0.35rem; } .transits { margin: 0; padding: 0; list-style: none; display: grid; gap: 0.25rem; } .transits li { font-size: var(--roxy-text-sm, 0.875rem); color: var(--roxy-fg, #0a0a0a); padding-left: 0.9rem; position: relative; } .transits li::before { content: ''; position: absolute; left: 0; top: 0.5em; width: 5px; height: 5px; border-radius: 50%; background: var(--roxy-accent, #f59e0b); } .block-title { margin: 0 0 var(--roxy-space-sm, 0.5rem); font-size: var(--roxy-text-xs, 0.75rem); color: var(--roxy-muted, #71717a); font-weight: var(--roxy-weight-bold, 600); text-transform: uppercase; letter-spacing: 0.06em; } .weeks { display: grid; gap: var(--roxy-space-sm, 0.5rem); } .week { display: grid; grid-template-columns: 3.5rem 1fr; gap: var(--roxy-space-sm, 0.5rem); align-items: baseline; border-top: 1px solid var(--roxy-border, #e4e4e7); padding-top: var(--roxy-space-sm, 0.5rem); } .week:first-child { border-top: 0; padding-top: 0; } .week-no { font-size: var(--roxy-text-xs, 0.75rem); color: var(--roxy-muted, #71717a); text-transform: uppercase; letter-spacing: 0.06em; font-weight: var(--roxy-weight-bold, 600); } .week-focus { font-weight: var(--roxy-weight-bold, 600); color: var(--roxy-fg, #0a0a0a); font-size: var(--roxy-text-sm, 0.875rem); } .week-advice { margin: 0.15rem 0 0; font-size: var(--roxy-text-sm, 0.875rem); color: var(--roxy-fg, #0a0a0a); } /* One grid for the whole list so every event starts on the same column, * however wide the longest date renders. The row wrapper keeps the * dt/dd pairing in the markup and drops out of the layout. */ .dates { margin: 0; display: grid; grid-template-columns: max-content 1fr; gap: 0.3rem var(--roxy-space-md, 1rem); font-size: var(--roxy-text-sm, 0.875rem); } .date-row { display: contents; } .date-row dt { font-variant-numeric: tabular-nums; color: var(--roxy-accent-ink, #b45309); font-weight: var(--roxy-weight-bold, 600); white-space: nowrap; } .date-row dd { margin: 0; color: var(--roxy-fg, #0a0a0a); } `, ]; @property({ type: String, reflect: true }) period: 'daily' | 'weekly' | 'monthly' = 'daily'; protected renderData(d: HoroscopeData) { const sign = d.sign ?? ''; const glyph = signGlyph(sign) ?? ''; const energy = 'energyRating' in d && typeof d.energyRating === 'number' ? d.energyRating : null; // `date` is an ISO day; `week` and `month` are already human ranges. const dateLabel = ('date' in d && d.date && formatDate(this.effectiveLang(), d.date)) || ('week' in d && d.week) || ('month' in d && d.month) || ''; return html`

${sign} ${this.period}

${dateLabel ? html`
${dateLabel}
` : nothing}
${ energy !== null ? html` ${this.t('Energy {{value}}/10', { value: energy })} ` : nothing }
${ // The sky strip below is the evidence for this paragraph; the paragraph // is the read of it. d.overview && !this.hideReadings ? html`

${d.overview}

` : nothing } ${this.renderSky(d)} ${ // Five headed paragraphs and nothing else, so the block goes whole rather // than leaving five headings over nothing. this.hideReadings ? nothing : html`
${ d.love ? html`

${this.t('Love')}

${d.love}

` : nothing } ${ d.career ? html`

${this.t('Career')}

${d.career}

` : nothing } ${ d.health ? html`

${this.t('Health')}

${d.health}

` : nothing } ${ d.finance ? html`

${this.t('Finance')}

${d.finance}

` : nothing } ${ 'advice' in d && d.advice ? html`

${this.t('Advice')}

${d.advice}

` : nothing }
` } ${this.renderMonth(d)} ${(() => { const luckyNumber = 'luckyNumber' in d && d.luckyNumber !== undefined ? d.luckyNumber : undefined; const luckyColor = 'luckyColor' in d && d.luckyColor ? d.luckyColor : ''; const luckyNumbers = 'luckyNumbers' in d && d.luckyNumbers ? d.luckyNumbers : []; const luckyDays = 'luckyDays' in d && d.luckyDays ? d.luckyDays : []; const compatibleSigns = d.compatibleSigns ?? []; if ( luckyNumber === undefined && !luckyColor && luckyNumbers.length === 0 && luckyDays.length === 0 && compatibleSigns.length === 0 ) return nothing; return html`
${ luckyNumber !== undefined ? html`${this.t('Lucky number')} ${luckyNumber}` : nothing } ${ luckyColor ? html`${this.t('Lucky color')} ${luckyColor}` : nothing } ${ luckyNumbers.length ? html`${this.t('Lucky numbers')} ${luckyNumbers.join(', ')}` : nothing } ${ luckyDays.length ? html`${this.t('Lucky days')} ${luckyDays.join(', ')}` : nothing } ${ compatibleSigns.length ? html` ${this.t('Best with')} ${compatibleSigns.map( (s) => html`${s}`, )} ` : nothing }
`; })()}
`; } /** * The sky behind the reading (daily only): where the Moon is, what phase it is in, and the transits the forecast was derived from. A daily horoscope that hides its transits is a fortune cookie; showing them is what makes this one auditable. */ private renderSky(d: HoroscopeData) { const moonSign = 'moonSign' in d ? d.moonSign : ''; const moonPhase = 'moonPhase' in d ? d.moonPhase : ''; const transits = ('activeTransits' in d ? d.activeTransits : []) ?? []; if (!moonSign && !moonPhase && transits.length === 0) return nothing; const glyph = signGlyph(moonSign) ?? ''; return html`
${ moonSign || moonPhase ? html`
${ moonSign ? html`${this.t('Moon')} ${moonSign}` : nothing } ${ moonPhase ? html`${this.t('Phase')} ${moonPhase}` : nothing }
` : nothing } ${ transits.length ? html`` : nothing }
`; } /** Monthly arc: the week-by-week focus and the dated events (lunations, retrogrades, ingresses) the month turns on. */ private renderMonth(d: HoroscopeData) { const weeks = ('weekByWeek' in d ? d.weekByWeek : []) ?? []; const keyDates = ('keyDates' in d ? d.keyDates : []) ?? []; if (weeks.length === 0 && keyDates.length === 0) return nothing; return html`${ // Each week is a focus phrase and a line of advice, so the block is prose // under its own heading and goes whole. weeks.length && !this.hideReadings ? html`

${this.t('Week by week')}

${weeks.map( (w) => html`
${this.t('Week {{n}}', { n: w.week })}
${w.focus}
${w.advice ? html`

${w.advice}

` : nothing}
`, )}
` : nothing } ${ // Dated lunations, retrogrades and ingresses: the month's ephemeris, not a // read of it, so it stays. keyDates.length ? html`

${this.t('Key dates')}

${keyDates.map( (k) => html`
${formatDate(this.effectiveLang(), k.date) || k.date}
${k.event}
`, )}
` : nothing }`; } } declare global { interface HTMLElementTagNameMap { 'roxy-horoscope-card': RoxyHoroscopeCard; } }