chain line: the logic is the connective — LaTeX ∧ and ⟹ via KaTeX (owner ruling)

'A → B → C' misread as chained implication when A and B are
CO-premises. The honest shape (owner, 2026-09-30): premises join
with the LaTeX LAND (∧), the conclusion separates by the LaTeX
IMPLICATION (⟹) — A ∧ B ⟹ C.

The glyphs are REAL KaTeX renders — components/latex-glyph.tsx wraps
katex.renderToString (cached per command; the app's math engine, the
stylesheet already rides app-wide from app/layout.tsx). No SVGs, no
font-dependent unicode. PROMINENT in the theme foreground (white in
the dark theme) per the owner's same-round ruling — the logic is the
payload, not the old dim gray. A lonely chip carries no glyph (it
reads as a standalone proposition); the plain-text '→' survives only
in the row's aria-label (formatChainLine, the screen-reader form).
This commit is contained in:
amosblomqvist
2026-09-30 01:35:59 +03:00
parent e7f4923159
commit 08e64466ad
4 changed files with 112 additions and 37 deletions
+44 -32
View File
@@ -21,26 +21,15 @@
* (vertical column hairlines removed with them, owner ruling: "anything
* else is better without them").
*
* THE CHAIN LINE: every proposition name is a faint-outlined CHIP with
* the → a clearly separated connector between chips — a lonely chip
* reads as a standalone proposition (the "(no premises)" annotation is
* dead in the visible UI; it survives only as the row's aria-label).
* Icons: ONLY the cluster FolderIcon survives (owner ruling, evening
* round 3: the chain waypoints icons are not needed — the chips +
* connectors already read as chains); propositions chip-only. Chevrons
* rotate with expansion. Statement rows show propositions PLAIN (owner
* ruling: the derivation is already encoded in the chain line — no
* conclusion marker down there).
*
* THE ALIGNMENT STAIRCASE (owner ruling, round 3): every level's
* checkbox sits ONE COLUMN under its parent's expander — aligned with
* the parent's ICON slot: open a folder and the chain checkboxes line
* up with the FOLDER icon; the statements step one more unit out (the
* would-be chain-icon column — the chains lost their icons but the
* pattern continues, owner: "double the indentation"). Geometry: every
* column is 14px + 4px gap = 18px and each step is TWO columns (36px) —
* clusters pl-3 (12px), chains pl-12 (48px = the cluster's folder
* icon), statements pl-21 (84px = where a chain icon WOULD sit).
* THE CHAIN LINE: every proposition name is a faint-outlined CHIP
* with the LOGIC as the connectives (owner rulings, 2026-09-30):
* premises join with the LaTeX LAND (∧) and the conclusion separates
* by the LaTeX IMPLICATION (⟹) — `A ∧ B ⟹ C`, never the misleading
* `A → B → C`. The glyphs are REAL KaTeX renders (components/
* latex-glyph.tsx — the app's math engine, never SVGs), PROMINENT in
* the theme foreground (white), not dim gray. A lonely chip (no
* premises) carries no glyph — it reads as a standalone proposition;
* "(no premises)" survives only as the row's aria-label.
*
* SORTING: all three columns sortable (owner ruling — "consistency is
* key"): Name (A→Z), Props (largest-first), Created (newest-first);
@@ -83,6 +72,7 @@ import type { GraphEdge, GraphNode } from "@/src/core";
import { TopBar } from "@/components/top-bar";
import { PracticeDialog } from "@/components/practice-dialog";
import { DeleteNodeDialog } from "@/components/delete-node-dialog";
import { LatexGlyph } from "@/components/latex-glyph";
/** The sortable columns (owner ruling: all three — consistency). */
export type ChainSortCol = "name" | "props" | "created";
@@ -405,20 +395,42 @@ export function ChainTableBody({
/>
<Expander open={chainOpen} onClick={() => onToggleChain(chain.id)} testid={`chain-toggle-${chain.id}`} label={`Expand chain ${nameOf(chain.conclusion)}`} />
{/* The chain line: proposition names as faint-outlined
chips, → the connector BETWEEN them (owner ruling) — a
lonely chip reads as a standalone proposition. */}
chips. THE LOGIC IS THE PAYLOAD (owner rulings,
2026-09-30): premises join with the LaTeX LAND (∧),
the conclusion separates by the LaTeX IMPLICATION
(⟹) — `A ∧ B ⟹ C`, never the misleading `A → B →
C` — the glyphs render through KaTeX (the app's
math engine), PROMINENT in the theme foreground
(white), not the old dim gray. A lonely chip reads
as a standalone proposition (no glyphs at all);
the row's aria-label keeps the plain-text form. */}
<span className="flex min-w-0 flex-1 flex-wrap items-center gap-x-0.5 gap-y-1 text-[13px] leading-tight">
{memberIds.map((id, i) => (
<span key={id} className="flex items-center">
{i > 0 && <span data-testid={`chain-arrow-${chain.id}-${i}`} className="px-1.5 text-muted-foreground/50">→</span>}
<span
data-testid={`prop-chip-${id}`}
className="rounded border border-border/70 bg-muted/30 px-1.5 py-0.5"
>
{nameOf(id)}
{memberIds.map((id, i) => {
const isConclusion = i === memberIds.length - 1;
return (
<span key={id} className="flex items-center">
{i > 0 && (isConclusion ? (
<LatexGlyph
cmd="\implies"
testid={`chain-implies-${chain.id}`}
className="px-1.5 text-foreground"
/>
) : (
<LatexGlyph
cmd="\land"
testid={`chain-and-${chain.id}-${i}`}
className="px-1.5 text-foreground"
/>
))}
<span
data-testid={`prop-chip-${id}`}
className="rounded border border-border/70 bg-muted/30 px-1.5 py-0.5"
>
{nameOf(id)}
</span>
</span>
</span>
))}
);
})}
</span>
<span className="w-14 shrink-0 text-right text-xs tabular-nums text-muted-foreground">
{memberIds.length}
+53
View File
@@ -0,0 +1,53 @@
import katex from "katex";
/**
* THE LaTeX glyph seam (owner ruling, 2026-09-30): UI connectors that
* want math symbols render the REAL LaTeX glyphs through KaTeX — the
* app's math engine, already shipped (app/layout.tsx carries the
* katex stylesheet) — never hand-rolled SVGs or font-dependent
* unicode. Born for the chain table's logical connectives: premises
* join with \land (∧), the conclusion separates with \implies (⟹) —
* `A ∧ B ⟹ C`, not the misleading `A → B → C`.
*
* Prominent by the same ruling: the connectives render in the
* theme's FOREGROUND (white in the dark theme) — the chain line's
* logic is the payload, not decoration. Callers pass the class.
*/
/** Cached katex HTML per command — the glyph set is tiny and
* renderToString is pure; one render per command per process. */
const cache = new Map<string, string>();
/** The raw katex HTML for a LaTeX command (pure, sync — SSR-safe). */
export function latexGlyphHtml(cmd: string): string {
let html = cache.get(cmd);
if (html === undefined) {
html = katex.renderToString(cmd, { throwOnError: false, output: "html" });
cache.set(cmd, html);
}
return html;
}
/** One rendered LaTeX glyph. `cmd` is the LaTeX command ("\land",
* "\implies"); the output is aria-hidden — callers carry the textual
* aria-label (formatChainLine keeps the plain-text form). */
export function LatexGlyph({
cmd,
testid,
className,
}: {
cmd: string;
testid?: string;
className?: string;
}) {
return (
<span
data-testid={testid}
aria-hidden="true"
className={className}
// katex.renderToString is the library's own trusted output
// (the same thing rehype-katex injects for markdown math).
dangerouslySetInnerHTML={{ __html: latexGlyphHtml(cmd) }}
/>
);
}
+1 -1
View File
File diff suppressed because one or more lines are too long
+14 -4
View File
@@ -5,7 +5,9 @@
* surface), cluster → chain → statement hierarchy (a chain lives in its
* CONCLUSION's birth cluster; expanding a chain reveals statements
* directly — names live in the line, never twice), proposition CHIPS +
* → connectors (a lonely chip = a standalone proposition; the "(no
* ∧/⟹ LaTeX connectives (premises join by land, the conclusion
* separates by implication — REAL KaTeX renders, prominent white; a
* lonely chip = a standalone proposition; the "(no
* premises)" annotation is dead), rotating chevrons, folder/waypoints
* icons, a floating bottom selection bar that never shifts layout, and
* a fuzzy search that auto-expands matching clusters.
@@ -164,14 +166,22 @@ describe("ChainTableBody — THE shared TopBar + the lines law (owner, round 2)"
});
describe("ChainTableBody — chain rows (chips + connectors)", () => {
it("names render as outlined chips with → connectors BETWEEN them", () => {
it("names render as outlined chips; premises join by ∧, the conclusion by ⟹ — REAL KaTeX glyphs in the foreground", () => {
const html = render({ expandedClusters: new Set(["chat1"]) });
assert.ok(html.includes('data-testid="chain-row-c1"'));
assert.ok(html.includes('data-testid="prop-chip-a"'), "premise chip");
assert.ok(html.includes('data-testid="prop-chip-c1"'), "conclusion chip");
assert.ok(html.includes("Premise one"), "chip text");
assert.ok(html.includes('data-testid="chain-arrow-c1-1"'), "the connector");
assert.ok(html.includes("→"), "the arrow glyph");
// the connectives (owner ruling, 2026-09-30): premises ∧, conclusion
// ⟹ — KaTeX renders (class katex present), PROMINENT foreground
assert.ok(html.includes('data-testid="chain-and-c1-1"'), "the land between premises");
assert.ok(html.includes('data-testid="chain-implies-c1"'), "the implication before the conclusion");
const and = html.match(/data-testid="chain-and-c1-1"[^>]*>/)?.[0] ?? "";
assert.match(and, /text-foreground/, "prominent — the theme's white, not dim gray");
assert.ok(html.includes("katex"), "a REAL KaTeX render, not a unicode arrow");
// the old arrow is dead as a VISIBLE connector — it survives only in
// the row's aria-label (formatChainLine, the plain-text form)
assert.doesNotMatch(html, />\s*→\s*</, "no textual arrow connector remains");
// owner ruling (round 3): the chain waypoints icons are GONE — the
// chips + connectors already read as chains; only folders keep icons
assert.ok(!html.includes("lucide-waypoints"), "no chain icon");