Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 28 additions & 8 deletions src/components/learn/References.astro
Original file line number Diff line number Diff line change
Expand Up @@ -57,10 +57,25 @@ const citations = [...citationIndex.values()].sort((a, b) => a.n - b.n);
)}

<style>
/* Links inside `formatReference()` output (DOI/ISBN/URL anchors) carry no
color class - they rely on the browser's default anchor color, left
untouched in light mode for pixel parity. Dark mode gets the standard
crystal-400 / gold-hover treatment used everywhere else in prose links. */
/* Links inside `formatReference()` output (DOI/ISBN/URL anchors) previously
carried no color class, so with the site's Tailwind preflight reset
(`a { color: inherit }`) they rendered in the same slate-600 body-text
color as the surrounding <span> with no underline — visually
indistinguishable from plain text in light mode. Scope the fix to
`a[href^="http"]`: every DOI/ISBN/URL anchor produced by formatReference()
is an absolute http(s) link, while the backlink (`href="#citeref-..."`)
uses a fragment href and already has its own explicit slate/crystal
styling above, so this selector cannot touch it. */
:global(.references a[href^="http"]) {
color: #0369a1; /* crystal-700, matches prose-a:text-crystal-700 */
text-decoration: underline;
text-decoration-color: rgba(3, 105, 161, 0.4);
text-underline-offset: 2px;
}
:global(.references a[href^="http"]:hover) {
color: #075985; /* crystal-800 */
text-decoration-color: currentColor;
}
:global([data-theme='dark'] .references a) {
color: #38bdf8; /* crystal-400 */
}
Expand All @@ -73,13 +88,18 @@ const citations = [...citationIndex.values()].sort((a, b) => a.n - b.n);
the row so it's obvious which reference was jumped to. `:target` only
matches the element whose id equals the URL fragment (the `<li
id="cite-{id}">` rendered above), so this only ever highlights one row
at a time. */
at a time.

Duration is 2.6s total: the first 35% (~0.9s) holds at near-full
intensity so the flash is reliably noticed even if the reader glances
away briefly, then eases out over the remaining ~1.7s. (Originally a
flat 1.2s ease-out, which read as too brief to reliably catch.) */
:global(.references li:target) {
animation: cite-target-flash 1.2s ease-out;
animation: cite-target-flash 2.6s ease-out;
border-radius: 0.375rem;
}
@keyframes cite-target-flash {
0% {
0%, 35% {
background-color: rgba(3, 105, 161, 0.16); /* crystal-700/16 */
box-shadow: -3px 0 0 0 #0369a1; /* crystal-700 left accent */
}
Expand All @@ -92,7 +112,7 @@ const citations = [...citationIndex.values()].sort((a, b) => a.n - b.n);
animation-name: cite-target-flash-dark;
}
@keyframes cite-target-flash-dark {
0% {
0%, 35% {
background-color: rgba(56, 189, 248, 0.14); /* crystal-400/14 */
box-shadow: -3px 0 0 0 #38bdf8; /* crystal-400 left accent */
}
Expand Down
67 changes: 64 additions & 3 deletions src/components/learn/SectionRenderer.astro
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,29 @@ interface Subsection {
table?: Table;
system?: CrystalSystem;
cdl?: string;
comparison?: Comparison;
callout?: CalloutData;
crystal?: Crystal;
}

// Keys of Subsection that the renderer below actually knows how to draw.
// Used only for the DEV-mode "unhandled subsection key" warning so a future
// authored field silently vanishing (like comparison/callout/crystal did
// before this fix) gets caught immediately instead of shipping unnoticed.
const KNOWN_SUBSECTION_KEYS = new Set([
'title',
'content',
'items',
'table',
'system',
'cdl',
'comparison',
'callout',
'crystal',
// Added by the pre-render step below, not authored content.
'renderedContent',
]);

interface Section {
title: string;
id?: string;
Expand Down Expand Up @@ -105,16 +126,35 @@ const renderMarkdown = async (text: string) => {
const sectionContent = section.content ? await renderMarkdown(section.content) : null;
const calloutContent = section.callout?.text ? await renderMarkdown(section.callout.text) : null;

// Pre-render subsection content
// Pre-render subsection content (including subsection-level callout text,
// which needs the same markdown + mineral-linking treatment as section
// callouts).
const subsectionContent = section.subsections
? await Promise.all(
section.subsections.map(async (sub) => ({
...sub,
renderedContent: sub.content ? await renderMarkdown(sub.content) : null,
renderedCalloutContent: sub.callout?.text ? await renderMarkdown(sub.callout.text) : null,
}))
)
: null;

// DEV-only guard: warn when an authored subsection carries a key this
// renderer doesn't know how to draw, so a future silent content-drop (like
// comparison/callout/crystal being stripped before this fix) surfaces
// immediately in the console instead of shipping unnoticed.
if (import.meta.env.DEV && section.subsections) {
for (const sub of section.subsections) {
const unknownKeys = Object.keys(sub).filter((key) => !KNOWN_SUBSECTION_KEYS.has(key));
if (unknownKeys.length > 0) {
console.warn(
`[SectionRenderer] Subsection "${sub.title}" in section "${section.title}" has ` +
`unhandled key(s): ${unknownKeys.join(', ')}. This content will not render.`
);
}
}
}

// Build section-level citation cluster HTML when section.citations is present.
function buildCitationCluster(ids: string[]): string {
if (!citationIndex || ids.length === 0) return '';
Expand Down Expand Up @@ -196,8 +236,14 @@ const sectionCitationCluster =
)}

{subsectionContent && subsectionContent.map(sub => {
// Check if this subsection is a crystal system
const detectedSystem = sub.system || detectCrystalSystem(sub.title);
// Check if this subsection is a crystal-system SUMMARY card. Subsections
// that author an explicit `crystal:` block (a CDL demo + caption, e.g.
// "Cubic Examples" in crystallography-advanced.yaml's "CDL Crystal
// Examples" section) must NOT be diverted here even though
// detectCrystalSystem() pattern-matches a system name in their title —
// they render via CrystalDemo in the regular-subsection branch below,
// same as section-level `section.crystal`.
const detectedSystem = sub.crystal ? null : (sub.system || detectCrystalSystem(sub.title));

if (detectedSystem) {
// Render as a crystal system card
Expand All @@ -218,6 +264,11 @@ const sectionCitationCluster =
<h3 class="text-lg font-semibold text-slate-800 dark:text-cream-primary">{sub.title}</h3>
</div>
<div class="p-4">
{sub.callout && sub.renderedCalloutContent && (
<Callout variant={sub.callout.type} title={sub.callout.title}>
<Fragment set:html={sub.renderedCalloutContent} />
</Callout>
)}
{sub.renderedContent && (
<div class="prose prose-slate prose-lg max-w-none mb-4
prose-headings:text-slate-900 dark:prose-headings:text-cream-primary
Expand All @@ -227,12 +278,22 @@ const sectionCitationCluster =
prose-a:text-crystal-700 dark:prose-a:text-crystal-400 dark:hover:prose-a:text-crystal-300 dark:hover:prose-a:decoration-gold"
set:html={sub.renderedContent} />
)}
{sub.crystal && (
<CrystalDemo
cdl={sub.crystal.cdl}
caption={sub.crystal.caption}
interactive={sub.crystal.interactive ?? true}
/>
)}
{sub.items && <PropertyList items={sub.items} headingLevel={4} citationIndex={citationIndex} />}
{sub.table && (
<div class="table-scroll-hint relative">
<DataTable headers={sub.table.headers} rows={sub.table.rows} caption={sub.table.caption} citationIndex={citationIndex} />
</div>
)}
{sub.comparison && (
<ComparisonBlock items={sub.comparison.items} headingLevel={4} />
)}
</div>
</div>
);
Expand Down
19 changes: 19 additions & 0 deletions src/content/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,11 +120,30 @@ const imageSchema = z.object({
});

// Schema for subsections (nested H3 content)
// NOTE: comparison/callout/crystal reuse the exact section-level schemas
// (comparisonSchema/calloutSchema/crystalSchema, defined above) because
// authored content nests these blocks under `subsections:` using the same
// shape as their section-level counterparts (verified against
// fundamentals/crystallography-advanced.yaml "Open vs Closed Forms" /
// "Cubic Examples" and equipment/other-tools.yaml callout subsections).
//
// `system`/`cdl` are a second, older, flatter shape for the same
// "render a crystal demo" intent — authored directly (not nested under
// `crystal:`) by fundamentals/crystal-systems.yaml, e.g.:
// subsections: [{ title: ..., system: cubic, cdl: "cubic[m3m]:..." }]
// Both shapes were previously stripped by zod (unknown keys are dropped by
// default) before ever reaching SectionRenderer.astro, even though the
// Subsection TS interface there already declared them.
const subsectionSchema = z.object({
title: z.string(),
content: z.string().optional(),
items: z.array(itemSchema).optional(),
table: tableSchema.optional(),
comparison: comparisonSchema.optional(),
callout: calloutSchema.optional(),
crystal: crystalSchema.optional(),
system: z.enum(['cubic', 'hexagonal', 'trigonal', 'tetragonal', 'orthorhombic', 'monoclinic', 'triclinic']).optional(),
cdl: z.string().optional(),
});

// Main section schema
Expand Down
30 changes: 20 additions & 10 deletions src/pages/learn/index.astro
Original file line number Diff line number Diff line change
Expand Up @@ -272,18 +272,28 @@ const iconPaths: Record<string, string> = {
<div class="max-w-3xl pb-8 border-b border-slate-200 dark:border-coffee-border">
<h1 class="text-4xl font-bold text-slate-900 dark:text-cream-primary">Learn Gemmology</h1>
<p class="mt-1 text-lg font-medium text-crystal-700 dark:text-crystal-300">Free FGA exam preparation and structured study guide</p>
<p class="mt-4 text-base text-slate-600 dark:text-cream-secondary">
<p class="mt-4 text-base text-slate-600 dark:text-cream-secondary max-w-2xl">
A structured, free study path for the Gem-A FGA Foundation and Diploma curriculum:
{totalTopicCount} articles across eight categories: crystal systems, physical and optical
{totalTopicCount} articles across eight categories crystal systems, physical and optical
properties, equipment, gem species, identification, phenomena, geographic origin, market
and grading, and gem care. Each article is paired with browser-based tools (refractometer
simulator, spectroscope band-matcher, treatment wizard, proportion analyzer) and an
SM-2-scheduled practice quiz so that theory and applied identification reinforce each
other in one place. Articles include pretest questions that count toward spaced-repetition
scheduling and link out to the canonical mineral database, so the same hardness, refractive
index, and birefringence values support reading, testing, and recall. Use the category grid
below to browse by topic, or start the daily review queue if you have an active study
schedule.
and grading, and gem care.
</p>
<ul class="mt-4 grid gap-x-6 gap-y-2 sm:grid-cols-3 text-sm text-slate-600 dark:text-cream-secondary">
<li class="flex items-start gap-2">
<span aria-hidden="true" class="mt-1 h-1.5 w-1.5 flex-shrink-0 rounded-full bg-crystal-700 dark:bg-crystal-300"></span>
<span>Paired with browser-based tools — refractometer simulator, spectroscope band-matcher, treatment wizard, proportion analyzer</span>
</li>
<li class="flex items-start gap-2">
<span aria-hidden="true" class="mt-1 h-1.5 w-1.5 flex-shrink-0 rounded-full bg-crystal-700 dark:bg-crystal-300"></span>
<span>Pretest questions in every article feed an SM-2-scheduled spaced-repetition quiz</span>
</li>
<li class="flex items-start gap-2">
<span aria-hidden="true" class="mt-1 h-1.5 w-1.5 flex-shrink-0 rounded-full bg-crystal-700 dark:bg-crystal-300"></span>
<span>Linked to the canonical mineral database, so hardness, RI, and birefringence values stay consistent everywhere</span>
</li>
</ul>
<p class="mt-4 text-sm text-slate-600 dark:text-cream-secondary">
Use the category grid below to browse by topic, or start the daily review queue if you have an active study schedule.
</p>
</div>

Expand Down
55 changes: 55 additions & 0 deletions tailwind.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,28 @@ export default {
p: {
maxWidth: 'none',
},
// NOTE: the DEFAULT block above defines custom `ol`, `ol > li`,
// `ol > li::before` (counter badge) and `ul > li::before` rules,
// but @tailwindcss/typography generates `.prose-lg` as a fully
// separate ruleset from its own built-in `lg` size config — it
// does NOT inherit DEFAULT's custom overrides. That built-in `lg`
// config sets its own `padding-inline-start` on `ol`, `ul`,
// `ol > li` and `ul > li` (~0.44em), and because `.prose-lg`'s
// rules are emitted after `.prose`'s in the stylesheet, its
// logical `padding-inline-start` wins the cascade over our
// physical `padding-left` from DEFAULT (per the CSS Logical
// Properties spec, a logical/physical pair for the same edge is
// resolved by cascade order, not by property name) — even though
// both were set with equal selector specificity. That silently
// shrank the reserved space for the absolute-positioned counter
// badge under `prose-lg`, so the badge (1.5em wide) overlapped
// the first ~19px of ordered-list text (regression from PR #55
// switching body copy to `prose-lg`). Fix: replicate the same
// rules here with BOTH the physical and logical property set,
// so this override always wins regardless of which one the
// browser resolves the box edge from. `ul > li` needed the same
// treatment (residual ~8px indent creeping back in), which is
// why it already carried `paddingInlineStart: '0'` below.
'ul': {
paddingLeft: '0',
paddingInlineStart: '0',
Expand All @@ -191,7 +213,40 @@ export default {
},
'ul > li': {
paddingLeft: '0',
paddingInlineStart: '0',
marginLeft: '0',
marginInlineStart: '0',
},
'ul > li::before': {
content: 'none',
},
'ol': {
listStyle: 'none',
paddingLeft: '0',
paddingInlineStart: '0',
counterReset: 'item',
},
'ol > li': {
position: 'relative',
paddingLeft: '2em',
paddingInlineStart: '2em',
counterIncrement: 'item',
},
'ol > li::before': {
content: 'counter(item)',
position: 'absolute',
left: '0',
top: '0',
width: '1.5em',
height: '1.5em',
fontSize: '0.75em',
fontWeight: '600',
color: '#0369a1',
backgroundColor: '#e0f2fe',
borderRadius: '0.25em',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
},
},
},
Expand Down
Loading