Skip to content

Commit 6b0f7b4

Browse files
committed
fix(components,utils,tests): correct aspect glyphs, centralize formatting, close the phone width layout gap
Aspect glyphs are now the nine Unicode astrological aspects (260C, 26BA, 2220, 26B9, 25A1, 25B3, 26BC, 26BB, 260D). Semisextile and quincunx were drawn with the XOR and NAND maths operators and were swapped, so every chart rendered a 150 degree quincunx with the 30 degree symbol and back. Semisquare and sesquiquadrate had no entry at all and the aspect grid printed the literal text ses. Aspect names had four divergent inline normalizers. The aspects endpoint returns SEMI SEXTILE where synastry returns SEMI_SEXTILE, and only the underscore was folded, so the space form missed every ASPECT_CLASS and ASPECT_SYMBOL lookup and the same aspect rendered coloured in one card and in the neutral fallback in another. normalizeAspect is now the single lookup key and formatAspectName the single label. Dates, ranges and ayanamsa move to utils/format.ts the same way: eleven components were concatenating raw ISO strings beside siblings that formatted the same field. formatDateTime accepts the split date plus time that birth details actually arrive as. Layout: guna-milan clipped its Score column off the card and local-space-compass pushed its header, dial, summary and table 170px past the edge at 375px. Both are the grid item min-width auto class. The e2e layout guard existed but ran only at the Playwright desktop default, and all three projects are Desktop, so nothing ever measured a card below 1280px. It now loops 375 / 768 / 1280, and both defects were reproduced by deleting the fix and re-running. Every tab now carries an aria-controls that resolves. The APG puts it on each element with role tab, not just the selected one; the helper emits an empty hidden panel for the inactive tabs so panel content stays lazy. Also: dasha bar dates take their grain from the shortest period and keep the year at day grain, forecast digest orders its window by significance, relocation names the ascendant sign in both branches instead of contradicting its own summary, roxy-data drops sections that would render empty, transits speed carries a unit, and headings are sentence case throughout. Pages deploys from the release run. The release: [published] trigger in pages.yml never fired once, because GitHub does not start a workflow from an event created with the default GITHUB_TOKEN, so the demo sat a release behind.
1 parent 649b802 commit 6b0f7b4

33 files changed

Lines changed: 625 additions & 148 deletions

.github/workflows/pages.yml

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,11 @@ on:
1414
- 'specs/openapi.json'
1515
- 'scripts/**'
1616
- '.github/workflows/pages.yml'
17-
release:
18-
types: [published]
17+
# No `release: [published]` trigger. It looked like it covered releases and
18+
# never fired once: GitHub does not start a workflow from an event created
19+
# with the default GITHUB_TOKEN, and release.yml creates the Release with
20+
# exactly that token. Releases now deploy Pages from inside the release.yml
21+
# `deploy-pages` job, which shares this concurrency group.
1922
workflow_dispatch:
2023

2124
permissions:

.github/workflows/release.yml

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ jobs:
2222
permissions:
2323
contents: write
2424
id-token: write
25+
outputs:
26+
version: ${{ steps.bump.outputs.version }}
2527
steps:
2628
- uses: actions/checkout@v7
2729
with:
@@ -206,6 +208,20 @@ jobs:
206208
git tag "v$VERSION"
207209
git push --follow-tags
208210
211+
# The Pages demo is deployed from THIS run, by the deploy-pages job below.
212+
# It cannot be left to pages.yml: the `release: [published]` trigger there
213+
# has never once fired, because GitHub does not start a workflow
214+
# from an event created with the default GITHUB_TOKEN, and both the
215+
# `release: vX` push and the GitHub Release here use exactly that token.
216+
# The demo therefore sat one commit behind every release. `apps/docs`
217+
# already holds the post-bump build from the "Bump versions and rebuild"
218+
# step, so this uploads the correct artifact with no extra build.
219+
- name: Upload Pages artifact
220+
if: steps.diff.outputs.changed == 'true' || github.event_name == 'workflow_dispatch'
221+
uses: actions/upload-pages-artifact@v5
222+
with:
223+
path: apps/docs
224+
209225
- name: Pack npm tarballs for release assets
210226
if: steps.diff.outputs.changed == 'true' || github.event_name == 'workflow_dispatch'
211227
run: |
@@ -224,3 +240,29 @@ jobs:
224240
make_latest: 'true'
225241
files: |
226242
.release-assets/*.tgz
243+
244+
# Deliberately a SEPARATE job. `actions/deploy-pages` requires the
245+
# `github-pages` environment, and an `environment:` on the release job would
246+
# add an `environment` claim to its OIDC token. The npm Trusted Publishers for
247+
# all three packages were configured WITHOUT an environment, so inlining this
248+
# would fail every publish. Keeping it behind `needs:` leaves those claims
249+
# byte-identical.
250+
deploy-pages:
251+
needs: release
252+
if: needs.release.outputs.version != ''
253+
runs-on: ubuntu-latest
254+
permissions:
255+
pages: write
256+
id-token: write
257+
environment:
258+
name: github-pages
259+
url: ${{ steps.deployment.outputs.page_url }}
260+
# Same group as pages.yml so a release deploy and a push deploy serialize
261+
# instead of racing. Not cancel-in-progress: a half-applied deploy is worse
262+
# than a slow one.
263+
concurrency:
264+
group: pages
265+
cancel-in-progress: false
266+
steps:
267+
- id: deployment
268+
uses: actions/deploy-pages@v5

packages/ui/src/components/angel-number-lookup.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,7 @@ export class RoxyAngelNumberLookup extends RoxyDataElement<AnalyzeNumberSequence
149149
<div class="hero">
150150
${d.number ? html`<div class="numeral">${d.number}</div>` : nothing}
151151
<div>
152-
<p class="label">Number analysis</p>
152+
${known?.title ? html`<p class="label">Number analysis</p>` : nothing}
153153
<h2 class="title">${heading}</h2>
154154
</div>
155155
</div>

packages/ui/src/components/aspects-table.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import type {
99
import { RoxyDataElement } from '../utils/base-element.js';
1010
import { baseStyles } from '../utils/base-styles.js';
1111
import { chevron, disclosureStyles } from '../utils/disclosure.js';
12-
import { formatNumber, normalizeAspect } from '../utils/format.js';
12+
import { formatAspectName, formatDate, formatNumber } from '../utils/format.js';
1313
import { interpAccordionStyles } from '../utils/interp-accordion.js';
1414
import { capitalize } from '../utils/string.js';
1515

@@ -296,7 +296,9 @@ export class RoxyAspectsTable extends RoxyDataElement<AspectsData> {
296296
return this.renderEmpty();
297297

298298
// Natal aspects date the chart, transits date the sky.
299-
const date = 'transitDate' in d ? d.transitDate : 'date' in d ? d.date : '';
299+
const date = formatDate(
300+
'transitDate' in d ? d.transitDate : 'date' in d ? d.date : '',
301+
);
300302
const options = 'options' in d ? d.options : undefined;
301303

302304
return html`<div class="wrap" aria-label="Aspects">
@@ -379,7 +381,7 @@ export class RoxyAspectsTable extends RoxyDataElement<AspectsData> {
379381
return html`<div class="strongest">
380382
<span class="label">Strongest</span>
381383
<span aria-hidden="true" class="glyph">${g1}</span>${s.planet1}
382-
<span class="nature-badge ${nature}">${normalizeAspect(s)}</span>
384+
<span class="nature-badge ${nature}">${formatAspectName(s)}</span>
383385
<span aria-hidden="true" class="glyph">${g2}</span>${s.planet2}
384386
<span class="meta">${s.isApplying ? 'Applying' : 'Separating'} · orb ${formatNumber(s.orb, 2)}° · str ${score(s.strength)}</span>
385387
</div>`;
@@ -394,7 +396,7 @@ export class RoxyAspectsTable extends RoxyDataElement<AspectsData> {
394396
const g1 = PLANET_GLYPH[capitalize(a.planet1)] ?? '';
395397
const g2 = PLANET_GLYPH[capitalize(a.planet2)] ?? '';
396398
const nature = (a.interpretation ?? 'neutral').toLowerCase();
397-
const type = (a.type ?? '').toLowerCase().replace(/_/g, ' ');
399+
const type = formatAspectName(a);
398400
const status = a.isApplying ? 'Applying' : 'Separating';
399401
// Natal aspects carry `meaning` (static aspect lore); transits carry `transitInterpretation` (timed guidance).
400402
const meaning = 'meaning' in a ? a.meaning : undefined;

packages/ui/src/components/astrocartography-map.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { AstrocartographyResponse } from '../types/index.js';
44
import { RoxyDataElement } from '../utils/base-element.js';
55
import { baseStyles } from '../utils/base-styles.js';
66
import { chevron, disclosureStyles } from '../utils/disclosure.js';
7+
import { formatDateTime } from '../utils/format.js';
78
import { interpAccordionStyles } from '../utils/interp-accordion.js';
89
import { planetColor } from '../utils/planet-color.js';
910
import { WORLD_LAND_PATH } from '../utils/world-map.js';
@@ -209,7 +210,7 @@ export class RoxyAstrocartographyMap extends RoxyDataElement<AstrocartographyRes
209210
${
210211
bd
211212
? html`<div class="meta">
212-
${[bd.date, bd.time].filter(Boolean).join(' · ')} ·
213+
${formatDateTime(bd.date, bd.time)} ·
213214
${formatLat(Math.round(bd.latitude))} ${formatLon(Math.round(bd.longitude))}
214215
</div>`
215216
: nothing

packages/ui/src/components/biorhythm-chart.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import type {
88
import { RoxyDataElement } from '../utils/base-element.js';
99
import { baseStyles } from '../utils/base-styles.js';
1010
import { disclosureStyles } from '../utils/disclosure.js';
11-
import { formatDate } from '../utils/format.js';
11+
import { formatDate, formatDateRange } from '../utils/format.js';
1212
import {
1313
type InterpSection,
1414
interpAccordionStyles,
@@ -315,7 +315,7 @@ export class RoxyBiorhythmChart extends RoxyDataElement<BiorhythmData> {
315315
return html`<section class="wrap" aria-label="Biorhythm forecast">
316316
<header class="head">
317317
<h2 class="title">Forecast</h2>
318-
<span class="energy">${[d.startDate, d.endDate].filter(Boolean).join(' - ')}</span>
318+
<span class="energy">${formatDateRange(d.startDate, d.endDate)}</span>
319319
</header>
320320
<svg
321321
viewBox="0 0 ${w} ${h}"
@@ -413,12 +413,17 @@ export class RoxyBiorhythmChart extends RoxyDataElement<BiorhythmData> {
413413
return html`<section class="wrap" aria-label="Critical days">
414414
<header class="head">
415415
<h2 class="title">Critical days</h2>
416-
<span class="energy">${[d.startDate, d.endDate].filter(Boolean).join(' - ')}</span>
416+
<span class="energy">${formatDateRange(d.startDate, d.endDate)}</span>
417417
</header>
418418
<dl class="stats">
419419
${this.stat('Events', typeof d.totalCriticalDays === 'number' ? `${d.totalCriticalDays}` : '')}
420420
${this.stat('Double days', doubles.length ? `${doubles.length}` : '0')}
421-
${this.stat('Triple day', d.tripleCriticalDay ? formatDate(d.tripleCriticalDay) : 'none')}
421+
${this.stat(
422+
'Triple day',
423+
d.tripleCriticalDay
424+
? formatDate(d.tripleCriticalDay)
425+
: 'None in range',
426+
)}
422427
</dl>
423428
${
424429
doubles.length > 0

packages/ui/src/components/choghadiya-grid.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { PLANET_GLYPH } from '../tokens/index.js';
44
import type { GetChoghadiyaResponse } from '../types/index.js';
55
import { RoxyDataElement } from '../utils/base-element.js';
66
import { baseStyles } from '../utils/base-styles.js';
7-
import { formatTimeRange } from '../utils/format.js';
7+
import { formatDate, formatTimeRange } from '../utils/format.js';
88
import { capitalize } from '../utils/string.js';
99

1010
type ChoghadiyaPeriod = GetChoghadiyaResponse['dayChoghadiya'][number];
@@ -175,7 +175,7 @@ export class RoxyChoghadiyaGrid extends RoxyDataElement<GetChoghadiyaResponse> {
175175
return html`<div class="wrap">
176176
<div class="header">
177177
<h2 class="title">Choghadiya</h2>
178-
${date ? html`<p class="subtitle">${date}</p>` : nothing}
178+
${date ? html`<p class="subtitle">${formatDate(date)}</p>` : nothing}
179179
</div>
180180
181181
<div class="cho-grid">

packages/ui/src/components/compatibility-card.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import {
1515
interpAccordionStyles,
1616
renderInterpAccordion,
1717
} from '../utils/interp-accordion.js';
18-
import { capitalize } from '../utils/string.js';
18+
import { capitalize, humanize } from '../utils/string.js';
1919

2020
type CompatibilityData =
2121
| CalculateCompatibilityResponse
@@ -286,7 +286,7 @@ export class RoxyCompatibilityCard extends RoxyDataElement<CompatibilityData> {
286286
aria-label=${`Compatibility (${this.mode})`}
287287
>
288288
<div class="head">
289-
<h2>${this.mode} compatibility</h2>
289+
<h2>${humanize(`${this.mode} compatibility`)}</h2>
290290
<div>
291291
${
292292
typeof score === 'number'

packages/ui/src/components/dasha-timeline.ts

Lines changed: 36 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,12 @@ import type {
1010
import { RoxyDataElement } from '../utils/base-element.js';
1111
import { baseStyles } from '../utils/base-styles.js';
1212
import { disclosureStyles } from '../utils/disclosure.js';
13-
import { formatDate, formatNumber } from '../utils/format.js';
13+
import {
14+
formatAyanamsa,
15+
formatDate,
16+
formatNumber,
17+
resolveDisplayDate,
18+
} from '../utils/format.js';
1419
import {
1520
type InterpSection,
1621
interpAccordionStyles,
@@ -258,6 +263,13 @@ export class RoxyDashaTimeline extends RoxyDataElement<DashaData> {
258263
const maxYears = periods.length
259264
? Math.max(...periods.map((p) => p.durationYears))
260265
: 0;
266+
// Grain comes from the SHORTEST bar, not the longest: the column has to
267+
// resolve every row, and the set is never uniform. A sookshma set whose
268+
// longest member is 73 days picked month grain, and its 8 day members
269+
// then printed both ends as the same month: `Saturn Jan 1990 - Jan 1990`.
270+
const grain = grainFor(
271+
periods.length ? Math.min(...periods.map((p) => p.durationYears)) : 0,
272+
);
261273
const readings = this.readings(d, periods);
262274
const frame = this.frameRows(d);
263275

@@ -315,7 +327,7 @@ export class RoxyDashaTimeline extends RoxyDataElement<DashaData> {
315327
${
316328
periods.length > 0
317329
? html`<div class="timeline" role="list">
318-
${periods.map((p) => this.renderBar(p, maxYears, grainFor(maxYears)))}
330+
${periods.map((p) => this.renderBar(p, maxYears, grain))}
319331
</div>`
320332
: nothing
321333
}`
@@ -387,11 +399,13 @@ export class RoxyDashaTimeline extends RoxyDataElement<DashaData> {
387399
]);
388400
}
389401
if ('ayanamsaType' in d && d.ayanamsaType) {
390-
const deg =
391-
'ayanamsa' in d && typeof d.ayanamsa === 'number'
392-
? ` (${formatNumber(d.ayanamsa, 3)}\u00b0)`
393-
: '';
394-
rows.push(['Ayanamsa', `${d.ayanamsaType}${deg}`]);
402+
rows.push([
403+
'Ayanamsa',
404+
formatAyanamsa(
405+
d.ayanamsaType,
406+
'ayanamsa' in d ? d.ayanamsa : undefined,
407+
),
408+
]);
395409
}
396410
if ('birthDashaBalance' in d && d.birthDashaBalance) {
397411
const lord =
@@ -625,18 +639,27 @@ function grainFor(maxYears: number): DateGrain {
625639
return 'day';
626640
}
627641

628-
/** One end of a bar, at the chosen granularity. Day grain drops the year: these rows sit under a parent line that already states it, and the column is only 8rem wide. */
642+
/**
643+
* One end of a bar, at the chosen granularity.
644+
*
645+
* @remarks
646+
* Every grain carries the year. Day grain used to drop it on the grounds that the parent line states it, but a sookshma period routinely straddles new year, so a bar read `28 Dec - 3 Jan` with nothing saying which side moved. A dasha table is a date reference: a practitioner reads a boundary off it and writes it down.
647+
*
648+
* The API returns naive datetimes (`1990-01-15T14:30:00`), which are wall clocks in the timezone of the CHART, not instants. {@link resolveDisplayDate} pins them to UTC so a boundary landing in the DST gap of the viewer cannot silently shift a day.
649+
*/
629650
function formatBoundary(s: string, grain: DateGrain): string {
630651
if (grain === 'year') {
631652
const m = s.match(/^(\d{4})/);
632653
return m ? m[1] : s;
633654
}
634-
const d = new Date(s);
655+
const { d, timeZone } = resolveDisplayDate(s);
635656
if (Number.isNaN(d.getTime())) return s;
636-
const month = d.toLocaleString('en', { month: 'short' });
637-
return grain === 'month'
638-
? `${month} ${d.getFullYear()}`
639-
: `${d.getDate()} ${month}`;
657+
return d.toLocaleDateString('en', {
658+
day: grain === 'day' ? 'numeric' : undefined,
659+
month: 'short',
660+
year: 'numeric',
661+
timeZone,
662+
});
640663
}
641664

642665
declare global {

packages/ui/src/components/data.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,22 @@ function isComplex(value: Json): boolean {
109109
);
110110
}
111111

112+
/**
113+
* True when a value would render as nothing: `[]`, `{}`, or an object whose every value is itself empty.
114+
*
115+
* @remarks
116+
* 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.
117+
*
118+
* Recursive because emptiness nests: `{ breakdown: { western: [], vedic: [] } }` has keys at the top level and still renders as nothing.
119+
*/
120+
function isEmptyValue(value: Json): boolean {
121+
if (value === null || value === undefined) return true;
122+
if (Array.isArray(value)) return value.length === 0;
123+
if (typeof value !== 'object') return false;
124+
const values = Object.values(value as Record<string, Json>);
125+
return values.length === 0 || values.every(isEmptyValue);
126+
}
127+
112128
@customElement('roxy-data')
113129
export class RoxyData extends RoxyDataElement<Json> {
114130
static styles = [
@@ -420,8 +436,7 @@ export class RoxyData extends RoxyDataElement<Json> {
420436
k !== summaryKey &&
421437
k !== quoteKey &&
422438
!SKIP_KEYS.includes(k) &&
423-
v !== null &&
424-
v !== undefined,
439+
!isEmptyValue(v),
425440
);
426441
// Scalars and primitive arrays fit the two-column rows; objects and
427442
// object arrays promote to full-width sections so nested tables are

0 commit comments

Comments
 (0)