From 29a997ef5cc7428308f9ed13fa8ffc1b6ee7d276 Mon Sep 17 00:00:00 2001 From: Bissbert <43237892+Bissbert@users.noreply.github.com> Date: Thu, 16 Jul 2026 16:30:02 +0200 Subject: [PATCH] fix(learn): sticky TOC, 18px body measure, unified fact lists, citation landing feedback - in-page table of contents for long articles (sticky desktop rail w/ IntersectionObserver scroll-spy + mobile collapsible details, >=4-section gate) fed by a shared section-id helper so anchors always match - body text now renders at the intended 18px / ~70ch measure (propagated prose-lg into SectionRenderer's nested prose scopes; tables unaffected) - PropertyList converged to a semantic single-column dl/dt/dd (unifies the three name/value fact grammars, better screen-reader semantics) - citation superscripts get an explicit light-mode crystal color + a :target landing flash on the referenced bibliography row (light/dark tints, reduced-motion fallback) - fixes: hub "Start here" 5th card (slug pointed at a bare directory, now .../overview), 390px filter placeholder clipping, DataTable mobile overflow fade hint - docs/authoring-learn.md documents when to use items:/table:/prose 502 tests, browser-verified in both themes. --- docs/authoring-learn.md | 74 ++++++++++ src/components/learn/PropertyList.astro | 147 +++++++++++-------- src/components/learn/References.astro | 48 +++++++ src/components/learn/SectionRenderer.astro | 95 ++++++++++-- src/components/learn/TableOfContents.astro | 160 +++++++++++++++++++++ src/components/learn/index.ts | 1 + src/lib/learn/section-id.test.ts | 20 +++ src/lib/learn/section-id.ts | 22 +++ src/pages/learn/[...slug].astro | 17 ++- src/pages/learn/index.astro | 7 +- 10 files changed, 512 insertions(+), 79 deletions(-) create mode 100644 docs/authoring-learn.md create mode 100644 src/components/learn/TableOfContents.astro create mode 100644 src/lib/learn/section-id.test.ts create mode 100644 src/lib/learn/section-id.ts diff --git a/docs/authoring-learn.md b/docs/authoring-learn.md new file mode 100644 index 0000000..7c709ea --- /dev/null +++ b/docs/authoring-learn.md @@ -0,0 +1,74 @@ +# Authoring Learn Content — Fact-Grammar Convention + +> **Diataxis type:** How-to — a short authoring convention for anyone writing or +> editing `src/content/learn/**/*.yaml`. +> +> **Audience:** contributors editing learn article YAML. Assumes familiarity +> with the section/subsection shape documented inline in +> `src/content/config.ts` (`sectionSchema`, `subsectionSchema`, `itemSchema`, +> `tableSchema`). + +## Why this doc exists + +A learn section can present a set of name/value facts three different ways: + +1. `items:` — rendered as a definition list by `PropertyList.astro`. +2. `table:` — rendered as a real `` by `DataTable.astro`. +3. Plain markdown bullets inside `content:` (`- **Name**: value`) — rendered + through `marked` into a generic `.prose ul` list. + +All three exist for good reasons, but before the learn-rendering P2 pass they +diverged more than the underlying data warranted: the same kind of fact +(a short name paired with a value or one-line description) could show up as a +bordered icon-card in one article and a plain bulleted line in the next, with +no content-driven reason for the difference. `PropertyList.astro` now renders +as a single-column definition list (divider between rows, no card) so it +reads as the same grammar as a markdown bullet list — the guidance below is +about **which one to reach for**, so that convergence doesn't erode again as +new content is authored. + +## Decision guide + +| Use | When | Example | +|---|---|---| +| `items:` | A short, scannable set of **name → value/description** facts that benefit from an icon, a monospace value chip, example-gem badges, or an item-level citation. Still fundamentally one fact per row. | Crystal axes/angles/point-groups; a species' key properties (RI, SG, hardness) | +| `table:` | Data that is **genuinely tabular** — more than one value column per row, or rows that only make sense read across multiple headers. | Origin comparison (origin × characteristics × market position); the 32 point groups (system × H-M symbol × type × gem example) | +| Plain markdown bullets in `content:` | Prose-adjacent facts that are part of the surrounding explanation, don't need a citation cluster or example badges, and read naturally as a sentence fragment after the term (`**Term**: explanation`). Also the right choice for anything that isn't a flat fact list at all (steps, caveats, a short aside). | "Distinction from cleavage" (two bullet definitions inside a paragraph of prose); a numbered/ordered explanation | + +**Rule of thumb:** if you find yourself writing `- **Name**: value` bullets +for *more than 3-4 items*, or the facts would benefit from per-item +citations, example badges, or a CDL/monospace value, move them to `items:` +instead — that's what the schema and renderer are for. Conversely, don't +reach for `items:` for a single aside fact buried in a paragraph; leave it as +prose. + +**Don't use `table:` for simple name/value pairs** just because it looks more +official — a two-column table where the second column is always a single +short value is exactly what `items:` is for. Reserve `table:` for data with a +genuine multi-column shape (three or more meaningful columns, or where a +reader needs to scan a column across rows, not just read row-by-row). + +## What NOT to do + +- Don't hand-author HTML tables or definition lists inside `content:` — use + `table:` / `items:` so citation markers (`{cite:id}`), mineral auto-linking, + and dark-mode styling are applied consistently by the renderers. +- Don't mix redundant grammars in one section (e.g. an `items:` block that + repeats the same facts as a `table:` in the same section) — pick one. +- Don't add `icon:` per item expecting a distinct icon per fact; icons are + looked up heuristically by name (`getIconForProperty` in + `src/components/learn/icons.ts`) and are decorative, not a content field to + fine-tune per item. + +## Where this is implemented + +- `src/components/learn/PropertyList.astro` — `items:` renderer (dl/dt/dd) +- `src/components/learn/DataTable.astro` — `table:` renderer +- `src/components/learn/SectionRenderer.astro` — renders `content:` markdown + through `marked`, and dispatches `items:`/`table:` to the two components + above +- `src/styles/global.css` (`.prose ul`, `.prose table`) plus + `tailwind.config.mjs` (`typography.extend`) — the shared markdown-bullet + and markdown-table styling +- `src/content/config.ts` — `itemSchema` / `tableSchema` / `sectionSchema` / + `subsectionSchema` — the Zod schema all three grammars validate against diff --git a/src/components/learn/PropertyList.astro b/src/components/learn/PropertyList.astro index 38db3a5..69986fc 100644 --- a/src/components/learn/PropertyList.astro +++ b/src/components/learn/PropertyList.astro @@ -1,12 +1,25 @@ --- /** - * PropertyList - Displays a grid of property items with icons. + * PropertyList - Displays simple name/value/description facts as a definition + * list (dl/dt/dd), one row per item with a divider between rows. + * + * Rendering-convergence note (learn-rendering P2): this used to render each + * item as a bordered, gradient-backed "card" in a 2-column grid, which read + * as a visually distinct UI from the same kind of fact authored as plain + * markdown `- **Name**: value` bullets (rendered via the shared `.prose ul` + * divider-list styling in global.css) or as a `table:` (DataTable.astro). + * Both PropertyList and plain markdown bullets exist to show simple + * name/value facts, so they're now visually aligned: single column, a + * divider between rows, no card border/background. DataTable stays + * visually distinct on purpose - it's for genuinely multi-column/tabular + * data, not name/value pairs. See docs/authoring-learn.md for the authoring + * guidance this convergence is meant to reinforce. + * * Accepts an optional citationIndex prop to resolve {cite:id} markers in * item description strings and render item-level citation clusters. */ -import Card from '../ui-astro/Card.astro'; import Badge from '../ui-astro/Badge.astro'; -import { getIconForProperty, iconPaths } from './icons'; +import { getIconForProperty } from './icons'; import { resolveCiteMarkers, formatCiteLabel, type CitationIndex } from '../../lib/citations/index'; interface Item { @@ -20,13 +33,21 @@ interface Item { interface Props { items: Item[]; + /** 'compact' tightens row padding; both variants render a single-column + * definition list now (no more multi-column card grid — see the P2 note + * above). */ variant?: 'default' | 'compact'; + /** Accepted for backwards compatibility with existing call sites. No + * longer used: item names render as `
` (a definition-list term, not a + * document heading) rather than an `

`/`

`, since a property name + * like "Axes" or "Angles" isn't a section heading and was polluting the + * page's heading outline. */ headingLevel?: 3 | 4; citationIndex?: CitationIndex; } -const { items, variant = 'default', headingLevel = 3, citationIndex } = Astro.props; -const HeadingTag = `h${headingLevel}` as 'h3' | 'h4'; +const { items, variant = 'default', citationIndex } = Astro.props; +const rowPadding = variant === 'compact' ? 'py-2' : 'py-3'; // Detect if value looks like CDL notation const isCDL = (value: string) => /^\w+\[\w+\]:\{/.test(value); @@ -81,71 +102,77 @@ const processedItems = items.map((item) => { }); --- -
+{/* + One `dl` group per item below: a flex-wrap row so `dt` + the inline value + `dd` share the first line (value pushed right via `ml-auto`), while any + longer `dd`s (description/citations/CDL/examples) carry `w-full` so they + wrap onto their own line. `dt`/`dd` stay direct children of the group + `div` (valid dl content model) rather than nesting another layout `div` + between them. +*/} +
{processedItems.map(item => ( -
-
- -
- - - -
+
+
+ + + + {item.name} +
-
- -
- {item.name} - {item.value && !isCDL(item.value) && ( - - {item.value} - - )} -
+ {item.value && !isCDL(item.value) && ( +
+ {item.value} +
+ )} - {/* Description — rendered as HTML when cite markers are present */} - {item.resolvedDesc && ( - item.descIsHtml - ?

- :

{item.resolvedDesc}

- )} + {/* Description — rendered as HTML when cite markers are present */} + {item.resolvedDesc && ( + item.descIsHtml + ?
+ :
{item.resolvedDesc}
+ )} - {/* Item-level citation cluster */} - {item.citeCluster && ( - - - - )} + {/* Item-level citation cluster */} + {item.citeCluster && ( +
+ +
+ )} - {/* CDL value block — already dark ("terminal" style); add a border so it - doesn't float unbounded against the coffee-raised card in dark mode. */} - {item.value && isCDL(item.value) && ( - - {item.value} - - )} + {/* CDL value block — already dark ("terminal" style); add a border so it + doesn't float unbounded against the page background in dark mode. */} + {item.value && isCDL(item.value) && ( +
+ + {item.value} + +
+ )} - {/* Example badges */} - {item.examples && item.examples.length > 0 && ( -
- {item.examples.map(ex => ( - {ex} - ))} -
- )} -
-
+ {/* Example badges */} + {item.examples && item.examples.length > 0 && ( +
+ {item.examples.map(ex => ( + {ex} + ))} +
+ )}
))} -
+
diff --git a/src/components/learn/SectionRenderer.astro b/src/components/learn/SectionRenderer.astro index 8847f6e..70e005f 100644 --- a/src/components/learn/SectionRenderer.astro +++ b/src/components/learn/SectionRenderer.astro @@ -9,6 +9,7 @@ import CrystalSystemCard from './CrystalSystemCard.astro'; import { detectCrystalSystem, type CrystalSystem } from './icons'; import { addMineralLinks } from '../../lib/mineral-linker'; import { resolveCiteMarkers, formatCiteLabel, type CitationIndex } from '../../lib/citations/index'; +import { sectionSlug } from '../../lib/learn/section-id'; interface Item { name: string; @@ -69,7 +70,7 @@ interface Props { } const { section, citationIndex } = Astro.props; -const sectionId = section.id || section.title.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, ''); +const sectionId = sectionSlug(section); // Configure marked for safe rendering marked.setOptions({ @@ -158,9 +159,9 @@ const sectionCitationCluster = )} {sectionContent && ( -
+
+ +
)} {section.comparison && ( @@ -216,16 +219,20 @@ const sectionCitationCluster =
{sub.renderedContent && ( -
)} {sub.items && } - {sub.table && } + {sub.table && ( +
+ +
+ )}
); @@ -254,10 +261,68 @@ const sectionCitationCluster = background-color: rgba(56, 189, 248, 0.1); /* crystal-400/10 */ } - /* `.citation-ref` (section-heading citation cluster) has no CSS definition - anywhere - relies on the browser's default anchor color in light mode - (left untouched for pixel parity). Add only the dark-mode color. */ + /* `.citation-ref` (section-heading citation cluster) previously had no + light-mode color and relied on the browser's default anchor blue, which + read as a generic link rather than a citation marker. Give it an + explicit crystal-700 to match the prose link ramp, keeping the existing + dark-mode crystal-400 override. */ + :global(.citation-ref) { + color: #0369a1; /* crystal-700, matches prose-a:text-crystal-700 */ + } :global([data-theme='dark'] .citation-ref) { color: #38bdf8; /* crystal-400 */ } + + /* Mobile overflow affordance for DataTable (P3). DataTable.astro owns its + own `overflow-x-auto` scroll container internally, so this wrapper adds + a right-edge fade purely at the CSS level without touching DataTable's + markup: `.has-overflow` is toggled by the script below only when the + table inside actually overflows its container, so short tables get no + visual clutter. The gradient is a translucent tint rather than a + solid-color match, since the underlying background varies between the + table header (gradient) and body (white/dark) — this reads correctly + over either. */ + .table-scroll-hint.has-overflow::after { + content: ''; + position: absolute; + top: 0; + right: 0; + bottom: 0; + width: 2rem; + pointer-events: none; + border-radius: 0 0.75rem 0.75rem 0; + background: linear-gradient(to left, rgba(15, 23, 42, 0.14), transparent); + } + :global([data-theme='dark']) .table-scroll-hint.has-overflow::after { + background: linear-gradient(to left, rgba(0, 0, 0, 0.45), transparent); + } + @media (prefers-reduced-motion: reduce) { + /* No animation involved here, but keep the hint fully static either way. */ + .table-scroll-hint.has-overflow::after { + transition: none; + } + } + + diff --git a/src/components/learn/TableOfContents.astro b/src/components/learn/TableOfContents.astro new file mode 100644 index 0000000..d56d47c --- /dev/null +++ b/src/components/learn/TableOfContents.astro @@ -0,0 +1,160 @@ +--- +/** + * TableOfContents - "On this page" navigation for long learn articles. + * + * Renders nothing when there are fewer than 4 top-level sections (short + * articles don't need a TOC). Ids are derived via the shared `sectionSlug()` + * helper so anchors always match the ids SectionRenderer.astro renders for + * the same sections - do not recompute slugs independently here. + * + * Renders two variants, each responsible for hiding itself at the "wrong" + * breakpoint via Tailwind's `lg:` variant, so the page only needs to + * include this component once: + * - Mobile ( above the article body. + * - Desktop (lg+): a sticky aside rail with scroll-spy highlighting. + * + * The page decides layout/grid placement; this component only owns its own + * content and internal responsive visibility. + */ +import { sectionSlug, type SectionLike } from '../../lib/learn/section-id'; + +interface Props { + sections: SectionLike[]; +} + +const { sections } = Astro.props; +const items = sections.map((section) => ({ + title: section.title, + slug: sectionSlug(section), +})); +const show = items.length >= 4; +--- + +{show && ( + <> + {/* Mobile: collapsible, closed by default, rendered above the article body */} +
+ + On this page + + +
+ + {/* Desktop: sticky rail with scroll-spy */} + + + + +)} + + diff --git a/src/components/learn/index.ts b/src/components/learn/index.ts index 849d40f..e0ebdf6 100644 --- a/src/components/learn/index.ts +++ b/src/components/learn/index.ts @@ -3,3 +3,4 @@ export { default as PropertyList } from './PropertyList.astro'; export { default as ComparisonBlock } from './ComparisonBlock.astro'; export { default as CrystalDemo } from './CrystalDemo.astro'; export { default as SectionRenderer } from './SectionRenderer.astro'; +export { default as TableOfContents } from './TableOfContents.astro'; diff --git a/src/lib/learn/section-id.test.ts b/src/lib/learn/section-id.test.ts new file mode 100644 index 0000000..e50f687 --- /dev/null +++ b/src/lib/learn/section-id.test.ts @@ -0,0 +1,20 @@ +import { describe, it, expect } from 'vitest'; +import { sectionSlug } from './section-id'; + +describe('sectionSlug', () => { + it('passes an explicit id through unchanged', () => { + expect(sectionSlug({ id: 'custom-id', title: 'Ignored Title' })).toBe('custom-id'); + }); + + it('slugifies a plain title to lowercase-with-hyphens', () => { + expect(sectionSlug({ title: 'Crystal Systems' })).toBe('crystal-systems'); + }); + + it('strips special characters not in [a-z0-9-]', () => { + expect(sectionSlug({ title: "Gübelin's Notes (2020)!" })).toBe('gbelins-notes-2020'); + }); + + it('collapses runs of whitespace into a single hyphen', () => { + expect(sectionSlug({ title: ' Multiple Spaces Here ' })).toBe('-multiple-spaces-here-'); + }); +}); diff --git a/src/lib/learn/section-id.ts b/src/lib/learn/section-id.ts new file mode 100644 index 0000000..275f431 --- /dev/null +++ b/src/lib/learn/section-id.ts @@ -0,0 +1,22 @@ +/** + * Shared section-id slug derivation for learn articles. + * + * Single source of truth for how a section's DOM id is computed, so that + * anchors built anywhere else (e.g. the table-of-contents rail) always match + * the ids SectionRenderer.astro actually renders. An explicit `section.id` + * (set in the YAML source) always wins; otherwise the title is slugified. + */ + +export interface SectionLike { + id?: string; + title: string; +} + +/** Derive the DOM id used for a learn-article section's `
` element. */ +export function sectionSlug(section: SectionLike): string { + if (section.id) return section.id; + return section.title + .toLowerCase() + .replace(/\s+/g, '-') + .replace(/[^a-z0-9-]/g, ''); +} diff --git a/src/pages/learn/[...slug].astro b/src/pages/learn/[...slug].astro index d1d7546..71537eb 100644 --- a/src/pages/learn/[...slug].astro +++ b/src/pages/learn/[...slug].astro @@ -5,7 +5,7 @@ import path from 'node:path'; import { getCollection } from 'astro:content'; import BaseLayout from '../../layouts/BaseLayout.astro'; import { Breadcrumb, PageNav } from '../../components/docs'; -import { SectionRenderer } from '../../components/learn'; +import { SectionRenderer, TableOfContents } from '../../components/learn'; import { Container, Card, Badge } from '../../components/ui-astro'; import LearnSchema from '../../components/seo/LearnSchema.astro'; import References from '../../components/learn/References.astro'; @@ -205,6 +205,10 @@ const hasCitations = citationIndex.size > 0; const orderedRefs = hasCitations ? [...citationIndex.values()].sort((a, b) => a.n - b.n).map((e) => e.ref) : []; + +// Table of contents rail (mirrors TableOfContents.astro's own >= 4 threshold +// so the layout only switches to two columns when a TOC actually renders). +const showToc = data.sections.length >= 4; --- @@ -223,9 +227,16 @@ const orderedRefs = hasCitations articleAuthor={data.author} references={orderedRefs} /> - + +
+ {showToc && ( +
+ +
+ )} +

{data.title}

@@ -308,6 +319,8 @@ const orderedRefs = hasCitations prev={prev ? { label: prev.data.title, href: `/learn/${prev.id}` } : currentIndex === 0 ? { label: 'Back to Learn', href: '/learn' } : undefined} next={next ? { label: next.data.title, href: `/learn/${next.id}` } : undefined} /> +
+
diff --git a/src/pages/learn/index.astro b/src/pages/learn/index.astro index ea0014e..638e4bb 100644 --- a/src/pages/learn/index.astro +++ b/src/pages/learn/index.astro @@ -144,7 +144,10 @@ const startHereSlugs = [ 'fundamentals/crystal-systems', 'fundamentals/physical-properties', 'fundamentals/optical-properties', - 'identification/inclusions', + // "identification/inclusions" is a subcategory directory, not a content + // entry — the actual collection id for its overview page is nested one + // level deeper (see src/content/learn/identification/inclusions/overview.yaml). + 'identification/inclusions/overview', 'identification/treatments', ]; const startHereTopics = startHereSlugs @@ -295,7 +298,7 @@ const iconPaths: Record = {