Commit 22f1adeb by PLN (Algolia)

feat(oblique): /oblique Eno/Schmidt strategies deck + design lab, on the App Router

Aurora-glass cosmos, frosted-glass cards with iridescent foil edges,
Fraunces + Space Mono. Hybrid DRAW/BROWSE: chilled 3D tarot flip (spring
42/22/1.3), semantic swipes (left discard, right favorite, up stash),
haptics, kept-spread; realtime typotolerant search with incremental
16-card rendering. Date-seeded daily card. State in localStorage + URL
hash only.

Each card face carries a unique deterministic sacred-geometry figure:
mulberry32 seeded on card id + hexagram codepoint drives ring count,
packing, rotation, polygon overlays, and dash patterns — 187/187 distinct,
with a radial-mask quiet zone keeping the central text zone clear.

App Router native: server pages export metadata, interactive bodies are
'use client' leaves (ObliqueClient / DesignClient), per cosmicfest
conventions. Data: 187 deduped strategies (ceejbot cross-edition superset)
tagged with I Ching hexagrams.

Validated on the production build: routes 200, no console errors, no
horizontal overflow, tap targets >=44px, browse incremental 16->48 on
scroll, geometry uniqueness 48/48 sampled distinct, daily card deals,
Fraunces 25px legible on mobile.
parent efc02813
......@@ -37,6 +37,7 @@ const RAW_RUNTIME_STATE =
["@types/react", "npm:18.3.27"],\
["classnames", "npm:2.5.1"],\
["date-fns", "npm:3.6.0"],\
["framer-motion", "virtual:599f9545309f05166d506895056809d0f5971d8f55d13475681739816b4bb6745eb725b53ed2108e22ea4ce46b8a2c491882d38a9948455117b545da3880eea7#npm:13.1.1"],\
["gray-matter", "npm:4.0.3"],\
["hydra-synth", "npm:1.4.0"],\
["jest", "virtual:599f9545309f05166d506895056809d0f5971d8f55d13475681739816b4bb6745eb725b53ed2108e22ea4ce46b8a2c491882d38a9948455117b545da3880eea7#npm:30.2.0"],\
......@@ -5181,6 +5182,35 @@ const RAW_RUNTIME_STATE =
"linkType": "HARD"\
}]\
]],\
["framer-motion", [\
["npm:13.1.1", {\
"packageLocation": "../../../.yarn/berry/cache/framer-motion-npm-13.1.1-d887ffa39d-10c0.zip/node_modules/framer-motion/",\
"packageDependencies": [\
["framer-motion", "npm:13.1.1"]\
],\
"linkType": "SOFT"\
}],\
["virtual:599f9545309f05166d506895056809d0f5971d8f55d13475681739816b4bb6745eb725b53ed2108e22ea4ce46b8a2c491882d38a9948455117b545da3880eea7#npm:13.1.1", {\
"packageLocation": "./.yarn/__virtual__/framer-motion-virtual-4b795ef92d/4/.yarn/berry/cache/framer-motion-npm-13.1.1-d887ffa39d-10c0.zip/node_modules/framer-motion/",\
"packageDependencies": [\
["framer-motion", "virtual:599f9545309f05166d506895056809d0f5971d8f55d13475681739816b4bb6745eb725b53ed2108e22ea4ce46b8a2c491882d38a9948455117b545da3880eea7#npm:13.1.1"],\
["@types/react", "npm:18.3.27"],\
["@types/react-dom", null],\
["motion-dom", "npm:13.1.1"],\
["motion-utils", "npm:13.0.0"],\
["react", "npm:18.3.1"],\
["react-dom", "virtual:599f9545309f05166d506895056809d0f5971d8f55d13475681739816b4bb6745eb725b53ed2108e22ea4ce46b8a2c491882d38a9948455117b545da3880eea7#npm:18.3.1"],\
["tslib", "npm:2.8.1"]\
],\
"packagePeers": [\
"@types/react-dom",\
"@types/react",\
"react-dom",\
"react"\
],\
"linkType": "HARD"\
}]\
]],\
["fs-extra", [\
["npm:11.1.0", {\
"packageLocation": "../../../.yarn/berry/cache/fs-extra-npm-11.1.0-2cd157426f-10c0.zip/node_modules/fs-extra/",\
......@@ -8148,6 +8178,25 @@ const RAW_RUNTIME_STATE =
"linkType": "HARD"\
}]\
]],\
["motion-dom", [\
["npm:13.1.1", {\
"packageLocation": "../../../.yarn/berry/cache/motion-dom-npm-13.1.1-1e31caae53-10c0.zip/node_modules/motion-dom/",\
"packageDependencies": [\
["motion-dom", "npm:13.1.1"],\
["motion-utils", "npm:13.0.0"]\
],\
"linkType": "HARD"\
}]\
]],\
["motion-utils", [\
["npm:13.0.0", {\
"packageLocation": "../../../.yarn/berry/cache/motion-utils-npm-13.0.0-6f418257d9-10c0.zip/node_modules/motion-utils/",\
"packageDependencies": [\
["motion-utils", "npm:13.0.0"]\
],\
"linkType": "HARD"\
}]\
]],\
["mri", [\
["npm:1.2.0", {\
"packageLocation": "../../../.yarn/berry/cache/mri-npm-1.2.0-8ecee0357d-10c0.zip/node_modules/mri/",\
......@@ -8849,6 +8898,7 @@ const RAW_RUNTIME_STATE =
["@types/react", "npm:18.3.27"],\
["classnames", "npm:2.5.1"],\
["date-fns", "npm:3.6.0"],\
["framer-motion", "virtual:599f9545309f05166d506895056809d0f5971d8f55d13475681739816b4bb6745eb725b53ed2108e22ea4ce46b8a2c491882d38a9948455117b545da3880eea7#npm:13.1.1"],\
["gray-matter", "npm:4.0.3"],\
["hydra-synth", "npm:1.4.0"],\
["jest", "virtual:599f9545309f05166d506895056809d0f5971d8f55d13475681739816b4bb6745eb725b53ed2108e22ea4ce46b8a2c491882d38a9948455117b545da3880eea7#npm:30.2.0"],\
......
No preview for this file type
'use client';
/* /oblique — Brian Eno & Peter Schmidt's "Oblique Strategies" creative deck.
Hybrid DRAW (ritual draw + swipe gestures + kept-spread) and BROWSE (fuzzy
searchable index). State lives in localStorage + URL hash; no DB/account.
Hash routes: #draw / #browse / #<id> (deep link to a specific card). */
import { useState, useEffect, useCallback, useRef } from 'react';
import { Fraunces, Space_Mono } from 'next/font/google';
import styles from '@/styles/oblique.module.css';
import AuroraBackground from '@/components/oblique/AuroraBackground';
import DrawView from '@/components/oblique/DrawView';
import BrowseView from '@/components/oblique/BrowseView';
import {
CARDS,
COUNT,
storage,
parseHash,
dailyCard,
todayString,
shareUrl,
} from '@/lib/oblique';
// Fraunces variable serif — all custom axes. No fixed weight (variable font).
// CSS var names are intentionally distinct from design.js's to avoid scope bleed.
// next/font emits the resolved font-family string into --font-ob-serif /
// --font-ob-mono on the page root; our CSS reads those into the --ob-serif /
// --ob-mono family stacks. Names differ from design.js on purpose.
const fraunces = Fraunces({
subsets: ['latin'],
axes: ['opsz', 'SOFT', 'WONK'],
display: 'swap',
variable: '--font-ob-serif',
});
const spaceMono = Space_Mono({
subsets: ['latin'],
weight: ['400', '700'],
display: 'swap',
variable: '--font-ob-mono',
});
const HOST_HINT = 'me.nech.pl';
export default function ObliquePage() {
const [mode, setMode] = useState('draw'); // 'draw' | 'browse'
const [favorites, setFavorites] = useState([]);
const [kept, setKept] = useState([]);
const [initialCardId, setInitialCardId] = useState(null);
const [dailyCaption, setDailyCaption] = useState('');
const [booted, setBooted] = useState(false);
const lastHash = useRef('');
// Boot: hydrate state from localStorage, resolve hash + daily-card rules.
useEffect(() => {
setFavorites(storage.getFavorites());
setKept(storage.getKept());
const parsed = parseHash(window.location.hash);
let startCardId = null;
let caption = '';
if (parsed.mode === 'browse') {
setMode('browse');
} else {
setMode('draw');
if (parsed.cardId) {
// shared deep link → that specific card
startCardId = parsed.cardId;
} else {
const today = todayString();
const seen = storage.getDailySeen();
if (seen !== today) {
// first visit of the day → auto-deal the daily card
const d = dailyCard();
startCardId = d.id;
caption = "today's card";
storage.setDailySeen(today);
storage.setLast(d.id);
} else {
// already seen today → resume last-drawn, or a fresh DEAL prompt (null)
const last = storage.getLast();
startCardId = last;
}
}
}
setInitialCardId(startCardId);
setDailyCaption(caption);
lastHash.current = window.location.hash || '';
setBooted(true);
}, []);
// Hash sync: external hashchange (back/forward, shared link) updates state.
useEffect(() => {
const onHash = () => {
const parsed = parseHash(window.location.hash);
if (parsed.mode === 'browse') setMode('browse');
else {
setMode('draw');
if (parsed.cardId) {
setInitialCardId(parsed.cardId);
setDailyCaption('');
}
}
lastHash.current = window.location.hash || '';
};
window.addEventListener('hashchange', onHash);
return () => window.removeEventListener('hashchange', onHash);
}, []);
// When our state changes, reflect into the hash (without clobbering a deep
// link id unless we're switching modes explicitly).
const pushHash = useCallback(
(h) => {
if (window.location.hash === h) return;
lastHash.current = h;
// replaceState avoids polluting history with every mode flip
history.replaceState(null, '', h ? `#${h}` : window.location.pathname);
},
[]
);
const switchMode = useCallback(
(m) => {
setMode(m);
pushHash(m === 'browse' ? 'browse' : 'draw');
},
[pushHash]
);
// Share: copy deep link to clipboard.
const onShare = useCallback(async (id) => {
const url = shareUrl(id);
try {
await navigator.clipboard.writeText(url);
} catch {
/* clipboard blocked — the toast still flashes; the URL is the source of truth */
}
}, []);
return (
<div className={`${styles.page} ${fraunces.variable} ${spaceMono.variable}`}>
<AuroraBackground />
<main className={styles.main}>
<header className={styles.topBar}>
<div className={styles.wordmarkWrap}>
<h1 className={styles.wordmark}>OBlique</h1>
<p className={styles.tagline}>
{COUNT} stratégies · Eno &amp; Schmidt · 1975
</p>
</div>
<div className={styles.modeToggle} role="tablist" aria-label="Mode">
<button
type="button"
role="tab"
aria-selected={mode === 'draw'}
className={`${styles.tabBtn} ${mode === 'draw' ? styles.tabOn : ''}`}
onClick={() => switchMode('draw')}
>
DRAW
</button>
<button
type="button"
role="tab"
aria-selected={mode === 'browse'}
className={`${styles.tabBtn} ${mode === 'browse' ? styles.tabOn : ''}`}
onClick={() => switchMode('browse')}
>
BROWSE
</button>
</div>
</header>
<section className={styles.stage}>
{booted && mode === 'draw' && (
<DrawView
cards={CARDS}
favorites={favorites}
setFavorites={setFavorites}
kept={kept}
setKept={setKept}
initialCardId={initialCardId}
dailyCaption={dailyCaption}
onShare={onShare}
/>
)}
{booted && mode === 'browse' && (
<BrowseView
cards={CARDS}
favorites={favorites}
setFavorites={setFavorites}
onShare={onShare}
/>
)}
{!booted && <div className={styles.boot} aria-hidden="true" />}
</section>
</main>
<footer className={styles.footer}>
<span className={styles.footerMono}>
{HOST_HINT}/oblique · {' '}<a href="/parvagues/">parvagues</a>
</span>
</footer>
</div>
);
}
'use client';
import { useState, useRef } from 'react';
import { motion, AnimatePresence, useReducedMotion, useScroll, useTransform } from 'framer-motion';
import {
Fraunces,
Space_Mono,
Cormorant_Garamond,
JetBrains_Mono,
EB_Garamond,
IBM_Plex_Mono,
Spectral,
Spline_Sans_Mono,
} from 'next/font/google';
import styles from '@/styles/oblique.design.module.css';
import strategies from '@/content/oblique/strategies.json';
// Geist_Mono isn't reliably exposed via next/font/google; Spline_Sans_Mono is the
// documented fallback for the Spectral × mono pairing.
const fraunces = Fraunces({ subsets: ['latin'], axes: ['opsz', 'SOFT', 'WONK'], display: 'swap', variable: '--font-ob-fraunces' });
const spaceMono = Space_Mono({ subsets: ['latin'], weight: ['400', '700'], display: 'swap', variable: '--font-ob-space-mono' });
const cormorant = Cormorant_Garamond({ subsets: ['latin'], weight: ['400', '500', '600'], display: 'swap', variable: '--font-ob-cormorant' });
const jetbrains = JetBrains_Mono({ subsets: ['latin'], weight: ['400', '500'], display: 'swap', variable: '--font-ob-jetbrains' });
const ebGaramond = EB_Garamond({ subsets: ['latin'], weight: ['400', '500'], display: 'swap', variable: '--font-ob-eb-garamond' });
const ibmPlexMono = IBM_Plex_Mono({ subsets: ['latin'], weight: ['400', '500'], display: 'swap', variable: '--font-ob-ibm-plex-mono' });
const spectral = Spectral({ subsets: ['latin'], weight: ['400', '500'], display: 'swap', variable: '--font-ob-spectral' });
const splineMono = Spline_Sans_Mono({ subsets: ['latin'], weight: ['400', '500'], display: 'swap', variable: '--font-ob-spline-mono' });
const fontVars = [fraunces.variable, spaceMono.variable, cormorant.variable, jetbrains.variable, ebGaramond.variable, ibmPlexMono.variable, spectral.variable, splineMono.variable].join(' ');
const CARDS = strategies.cards;
const COUNT = strategies.count;
const SAMPLE = { text: 'A line has two sides', ordinal: '042/187', hex: '䷂' };
const PALETTES = [
{ key: 'void', name: 'Void + paper', cls: styles.palVoid },
{ key: 'amber', name: 'Amber CRT', cls: styles.palAmber },
{ key: 'iridescent', name: 'Iridescent oil-on-paper', cls: styles.palIridescent },
{ key: 'aurora', name: 'Aurora-glass', cls: styles.palAurora },
{ key: 'fused', name: 'Fused 5th', cls: styles.palFused },
];
const PAIRINGS = [
{ name: 'Fraunces × Space Mono', serif: 'var(--font-ob-fraunces)', mono: 'var(--font-ob-space-mono)', vars: `${fraunces.variable} ${spaceMono.variable}` },
{ name: 'Cormorant × JetBrains Mono', serif: 'var(--font-ob-cormorant)', mono: 'var(--font-ob-jetbrains)', vars: `${cormorant.variable} ${jetbrains.variable}` },
{ name: 'EB Garamond × IBM Plex Mono', serif: 'var(--font-ob-eb-garamond)', mono: 'var(--font-ob-ibm-plex-mono)', vars: `${ebGaramond.variable} ${ibmPlexMono.variable}` },
{ name: 'Spectral × Spline Sans Mono', serif: 'var(--font-ob-spectral)', mono: 'var(--font-ob-spline-mono)', vars: `${spectral.variable} ${splineMono.variable}` },
];
// Flower-of-Life line motif — 19 circles on a triangular lattice (hex distance ≤ 2).
const FLOWER_R = 13;
function flowerCenters() {
const out = [];
for (let i = -2; i <= 2; i++) {
for (let j = -2; j <= 2; j++) {
const k = -i - j;
if (Math.max(Math.abs(i), Math.abs(j), Math.abs(k)) > 2) continue;
out.push({ x: FLOWER_R * (i + j / 2), y: FLOWER_R * j * (Math.sqrt(3) / 2) });
}
}
return out;
}
const FLOWER_CENTERS = flowerCenters();
function FlowerOfLife({ size = 160, stroke = 'rgba(126,232,208,0.6)', className }) {
const c = FLOWER_CENTERS;
const viewBox = 100;
return (
<svg width={size} height={size} viewBox={`-${viewBox} -${viewBox} ${viewBox * 2} ${viewBox * 2}`} className={className} aria-hidden="true">
<g fill="none" stroke={stroke} strokeWidth="0.8">
{c.map((p, idx) => (
<circle key={idx} cx={p.x} cy={p.y} r={FLOWER_R} />
))}
</g>
</svg>
);
}
function CardSpecimen({ palette, sample = SAMPLE }) {
const isAmber = palette.key === 'amber';
const isIridescent = palette.key === 'iridescent';
const isAurora = palette.key === 'aurora';
const isFused = palette.key === 'fused';
const hasFoil = isIridescent || isFused;
const hasAurora = isAurora || isFused;
const inner = (
<div className={`${styles.tileCard} ${isAmber ? `${styles.scanlines} ${styles.grain}` : ''} ${hasAurora ? '' : ''}`}>
{hasAurora && (
<span className={styles.auroraBg} aria-hidden="true">
<motion.span
className={styles.auroraBlob}
animate={{ x: [0, 18, -12, 0], y: [0, -14, 10, 0], scale: [1, 1.08, 0.96, 1] }}
transition={{ duration: 14, repeat: Infinity, ease: 'easeInOut' }}
/>
</span>
)}
<span className={styles.tileHex} aria-hidden="true">{sample.hex}</span>
<span className={styles.tileLabel}>{palette.name}</span>
<p className={styles.tileText}>{sample.text}</p>
<span className={styles.tileFooter}>{sample.ordinal} · {sample.hex}</span>
</div>
);
if (hasFoil) {
return (
<div className={styles.foilWrap}>
<motion.span
className={styles.foilLayer}
animate={{ rotate: isIridescent ? [0, 360] : [0, 360] }}
transition={{ duration: 18, repeat: Infinity, ease: 'linear' }}
aria-hidden="true"
/>
{inner}
</div>
);
}
return inner;
}
function AnatomyCard({ variant, sample = SAMPLE }) {
return (
<div className={`${styles.anatomyCard} ${styles.glass}`}>
{variant !== 'minimal' && (
<span className={styles.geoBg} aria-hidden="true">
<FlowerOfLife size={150} stroke="rgba(126,232,208,0.5)" />
</span>
)}
{variant === 'rich' || variant === 'richmeta' ? (
<span className={styles.anatomyHexTop} aria-hidden="true">{sample.hex}</span>
) : null}
{variant === 'nofav' ? <span className={styles.anatomyHexTop} aria-hidden="true">{sample.hex}</span> : null}
<p className={styles.anatomyText}>{sample.text}</p>
<span className={styles.anatomyMeta}>
<span>{sample.ordinal}</span>
{variant === 'rich' || variant === 'richmeta' ? <span className={styles.anatomyFav}> fav</span> : null}
{variant === 'richmeta' ? <span>iv/187</span> : null}
{variant === 'richmeta' ? <span> share</span> : null}
{variant === 'minimal' ? <span>{sample.hex}</span> : null}
</span>
</div>
);
}
export default function ObliqueDesignLab() {
const [expanded, setExpanded] = useState(null); // palette key or null
const [activePair, setActivePair] = useState(3); // spectral × spline default
const [mode, setMode] = useState(null); // 'flip' | 'toss' | null
const [draw, setDraw] = useState(0); // draw counter
const [card, setCard] = useState(null);
const reduce = useReducedMotion();
const bannerRef = useRef(null);
const { scrollYProgress } = useScroll({ target: bannerRef, offset: ['start start', 'end start'] });
const bannerY = useTransform(scrollYProgress, [0, 1], [0, reduce ? 0 : -24]);
const bannerOpacity = useTransform(scrollYProgress, [0, 1], [1, reduce ? 1 : 0.6]);
const pick = () => {
const next = CARDS[Math.floor(Math.random() * CARDS.length)];
setCard(next);
setDraw((d) => d + 1);
};
const toggleExpand = (key) => setExpanded((cur) => (cur === key ? null : key));
const flipVariants = reduce
? { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 } }
: {
initial: { rotateY: 180, opacity: 0.4 },
animate: { rotateY: 0, opacity: 1, rotateZ: -2 },
exit: { opacity: 0, rotateY: 0 },
};
const flipTransition = reduce
? { duration: 0.3 }
: { type: 'spring', stiffness: 120, damping: 14 };
const tossVariants = reduce
? { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 } }
: {
initial: { x: 500, y: -300, rotate: 45, scale: 0.5, opacity: 0 },
animate: { x: 0, y: 0, rotate: 0, scale: 1, opacity: 1 },
exit: { opacity: 0, scale: 0.85, x: -260 },
};
const tossTransition = reduce
? { duration: 0.3 }
: { type: 'spring', stiffness: 90, damping: 12, mass: 0.85 };
const drawnOrdinal = card ? `${String(card.id).padStart(3, '0')}/${COUNT}` : null;
return (
<div className={`${styles.page} ${fontVars}`}>
<header className={styles.banner}>
<span className={styles.bannerBrand}>
<b>OBlique</b> <i>· design lab — pick a direction</i>
</span>
<a className={styles.bannerBack} href="/"> back to /</a>
</header>
{/* banner parallax strip */}
<div ref={bannerRef} style={{ position: 'relative', overflow: 'hidden' }}>
<motion.div
style={{ y: bannerY, opacity: bannerOpacity }}
className={styles.main}
>
<div style={{ padding: 'clamp(2.5rem, 8vw, 4rem) 0 1rem' }}>
<p className={styles.sectionNum}>01 04 / specimens for a forthcoming deck</p>
<h2 className={styles.sectionTitle}>Oblique Strategies, in five lights</h2>
<p className={styles.sectionLede}>
Eno &amp; Schmidt&rsquo;s deck, re-cast five ways. Tap a tile to expand it. Toggle type. Inspect the
anatomy. Then draw a card.
</p>
</div>
</motion.div>
</div>
<main className={styles.main}>
{/* ── §1 PALETTE TILES ── */}
<section className={styles.section}>
<div className={styles.sectionHead}>
<span className={styles.sectionNum}>01 / palettes</span>
<h3 className={styles.sectionTitle}>Five directions, one sample</h3>
<p className={styles.sectionLede}>Tap a tile to expand a full-size specimen. Tap again to collapse.</p>
</div>
<div
className={styles.tilesGrid}
style={{
'--ob-font-serif': PAIRINGS[activePair].serif,
'--ob-font-mono': PAIRINGS[activePair].mono,
}}
>
{PALETTES.map((p) => (
<motion.button
key={p.key}
type="button"
layout={!reduce}
className={`${styles.tile} ${p.cls} ${expanded === p.key ? styles.tileExpanded : ''}`}
onClick={() => toggleExpand(p.key)}
aria-expanded={expanded === p.key}
transition={reduce ? { duration: 0 } : { type: 'spring', stiffness: 260, damping: 30 }}
>
<CardSpecimen palette={p} />
</motion.button>
))}
</div>
</section>
{/* ── §2 TYPOGRAPHY ── */}
<section className={styles.section}>
<div className={styles.sectionHead}>
<span className={styles.sectionNum}>02 / typography</span>
<h3 className={styles.sectionTitle}>Four serif × mono pairings</h3>
<p className={styles.sectionLede}>Pick a pairing it re-types the palette tiles above.</p>
</div>
<div className={styles.pairToggle} role="radiogroup" aria-label="Typography pairing">
{PAIRINGS.map((pair, idx) => (
<span key={pair.name}>
<input
type="radio"
id={`pair-${idx}`}
name="pairing"
className={styles.pairRadio}
checked={activePair === idx}
onChange={() => setActivePair(idx)}
/>
<label htmlFor={`pair-${idx}`} className={styles.pairRadioLabel}>{pair.name}</label>
</span>
))}
</div>
<div className={styles.pairGrid}>
{PAIRINGS.map((pair) => (
<div key={pair.name} className={`${styles.pairSpecimen} ${pair.vars}`}>
<p className={styles.pairName}>{pair.name}</p>
<p className={styles.pairSerif} style={{ fontFamily: pair.serif }}>{SAMPLE.text}</p>
<p className={styles.pairMono} style={{ fontFamily: pair.mono }}>{SAMPLE.ordinal} · {SAMPLE.hex}</p>
</div>
))}
</div>
</section>
{/* ── §3 ANATOMY ── */}
<section className={styles.section}>
<div className={styles.sectionHead}>
<span className={styles.sectionNum}>03 / card anatomy</span>
<h3 className={styles.sectionTitle}>Four faces, aurora-glass default</h3>
<p className={styles.sectionLede}>Minimal, rich-disciplined, rich+meta, and no-on-card-fav.</p>
</div>
<div className={styles.anatomyGrid}>
<div>
<p className={styles.anatomyName}>Minimal</p>
<AnatomyCard variant="minimal" />
</div>
<div>
<p className={styles.anatomyName}>Rich-disciplined</p>
<AnatomyCard variant="rich" />
</div>
<div>
<p className={styles.anatomyName}>Rich + meta</p>
<AnatomyCard variant="richmeta" />
</div>
<div>
<p className={styles.anatomyName}>No on-card fav</p>
<AnatomyCard variant="nofav" />
</div>
</div>
<div className={styles.geoDivider} aria-hidden="true">
<FlowerOfLife size={120} stroke="rgba(126,232,208,0.7)" />
</div>
</section>
{/* ── §4 REVEAL PLAYGROUND ── */}
<section className={styles.section}>
<div className={styles.sectionHead}>
<span className={styles.sectionNum}>04 / reveal playground</span>
<h3 className={styles.sectionTitle}>Draw a card from {COUNT}</h3>
<p className={styles.sectionLede}>Tarot flip, or physics toss. Re-draws animate out in.</p>
</div>
<div className={styles.revealStage}>
{!card && !mode && <p className={styles.revealHint}>choose a reveal </p>}
<div className={styles.revealScene}>
<AnimatePresence mode="wait">
{card && mode === 'flip' && (
<motion.div
key={`flip-${draw}`}
className={styles.flipCard}
initial={flipVariants.initial}
animate={flipVariants.animate}
exit={flipVariants.exit}
transition={flipTransition}
style={{ transformStyle: 'preserve-3d' }}
>
<div className={`${styles.flipFace} ${styles.flipBack}`}>
<span className={styles.flipBackHex} aria-hidden="true">{card.hex}</span>
</div>
<div className={`${styles.flipFace} ${styles.flipFront}`}>
<span className={styles.drawHex} aria-hidden="true">{card.hex}</span>
<p className={styles.drawText}>{card.text}</p>
<span className={styles.drawFooter}>{drawnOrdinal} · {card.hex}</span>
</div>
</motion.div>
)}
{card && mode === 'toss' && (
<motion.div
key={`toss-${draw}`}
className={styles.tossCard}
initial={tossVariants.initial}
animate={tossVariants.animate}
exit={tossVariants.exit}
transition={tossTransition}
>
<span className={styles.drawHex} aria-hidden="true">{card.hex}</span>
<p className={styles.drawText}>{card.text}</p>
<span className={styles.drawFooter}>{drawnOrdinal} · {card.hex}</span>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
<div className={styles.revealButtons}>
<button
type="button"
className={styles.revealBtn}
onClick={() => { setMode('flip'); pick(); }}
>3D tarot flip</button>
<button
type="button"
className={styles.revealBtn}
onClick={() => { setMode('toss'); pick(); }}
>Physics toss</button>
<span className={styles.revealCounter}>draws: {draw}</span>
</div>
</section>
</main>
<footer className={styles.footer}>
oblique · design lab · {CARDS.length} strategies on file
</footer>
</div>
);
}
import DesignClient from './DesignClient';
// Internal design lab: five palettes, four type pairings, four card anatomies.
// Kept out of sitemaps by having no inbound links — it is a working surface.
export const metadata = {
title: { absolute: 'Oblique — Design Lab' },
description: "Oblique Strategies design lab — five palette directions, typography pairings, card anatomy, and reveal playground. Pick a direction.",
robots: { index: false, follow: false },
};
export const viewport = {
themeColor: "#0d1117",
};
export default function ObliqueDesignPage() {
return <DesignClient />;
}
import ObliqueClient from './ObliqueClient';
// The old Pages <Head> lived inside the component; App Router carries it here.
const DESCRIPTION =
"Oblique Strategies — Brian Eno & Peter Schmidt. 187 stratégies pour contourner le blocage créatif. Tire une carte, glisse, garde.";
const SHARE_BLURB = "187 stratégies créatives d'Eno & Schmidt. Tire une carte.";
export const metadata = {
title: { absolute: 'OBlique — 187 stratégies' },
description: DESCRIPTION,
openGraph: {
title: "Oblique Strategies",
description: SHARE_BLURB,
type: 'website',
},
twitter: {
card: 'summary',
title: "Oblique Strategies",
description: SHARE_BLURB,
},
};
export const viewport = {
themeColor: "#0d1117",
};
export default function ObliquePage() {
return <ObliqueClient />;
}
'use client';
/* Ambient aurora + paper-grain for the oblique page.
Three large blurred radial blobs drifting slowly via framer-motion loops.
Honors prefers-reduced-motion (static) and prefers-reduced-data (frozen, no
animation cost). Built from few elements with big blur to stay cheap on
low-end Android — repaints are bounded to these three transform-animated
layers. */
import { motion, useReducedMotion } from 'framer-motion';
import styles from '@/styles/oblique.module.css';
const BLOBS = [
{
color: 'rgba(15, 181, 181, 0.55)', // deep teal #0fb5-ish
size: '62vmax',
keyframes: {
x: ['-8vw', '12vw', '-4vw', '-8vw'],
y: ['-6vh', '8vh', '14vh', '-6vh'],
scale: [1, 1.18, 0.92, 1],
},
duration: 28,
},
{
color: 'rgba(124, 58, 237, 0.50)', // purple #7c3aed
size: '54vmax',
keyframes: {
x: ['10vw', '-12vw', '6vw', '10vw'],
y: ['10vh', '-4vh', '12vh', '10vh'],
scale: [0.9, 1.12, 1.04, 0.9],
},
duration: 32,
},
{
color: 'rgba(79, 70, 229, 0.42)', // indigo
size: '46vmax',
keyframes: {
x: ['-4vw', '8vw', '-14vw', '-4vw'],
y: ['12vh', '10vh', '-8vh', '12vh'],
scale: [1.04, 0.86, 1.16, 1.04],
},
duration: 24,
},
];
// Tiny repeating SVG turbulence as a data URI — grain at ~4% over the scene.
// FeTurbulence baseFrequency ~0.9 gives a fine paper texture.
const GRAIN_URI =
"data:image/svg+xml;utf8," +
encodeURIComponent(
"<svg xmlns='http://www.w3.org/2000/svg' width='160' height='160'>" +
"<filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/>" +
"<feColorMatrix type='saturate' values='0'/></filter>" +
"<rect width='100%' height='100%' filter='url(%23n)' opacity='0.5'/></svg>"
);
export default function AuroraBackground() {
const reduce = useReducedMotion();
// prefers-reduced-data: freeze aurora to skip the animation cost entirely.
const reducedData =
typeof window !== 'undefined' &&
window.matchMedia &&
window.matchMedia('(prefers-reduced-data: reduce)').matches;
const frozen = reduce || reducedData;
return (
<div className={styles.aurora} aria-hidden="true">
{BLOBS.map((b, i) => (
<motion.div
key={i}
className={styles.auroraBlob}
style={{
background: `radial-gradient(circle, ${b.color} 0%, transparent 62%)`,
width: b.size,
height: b.size,
}}
animate={frozen ? undefined : b.keyframes}
transition={
frozen
? undefined
: { duration: b.duration, repeat: Infinity, ease: 'easeInOut' }
}
/>
))}
<div className={styles.grain} style={{ backgroundImage: `url("${GRAIN_URI}")` }} />
</div>
);
}
'use client';
/* BROWSE mode — the index.
Realtime typotolerant search (subsequence or includes, includes ranked
first), match count, favorites-only toggle, responsive grid with generous
desktop breathing room. Tap a card → focused overlay with the full card
(flip-in), share + favorite. Keyboard: Enter opens, Escape closes. */
import { useState, useMemo, useCallback, useEffect, useRef } from 'react';
import { motion, AnimatePresence, useReducedMotion } from 'framer-motion';
import ObliqueCard from './ObliqueCard';
import { searchCards, cardById, storage } from '@/lib/oblique';
import styles from '@/styles/oblique.module.css';
const FLIP_SPRING = { type: 'spring', stiffness: 42, damping: 22, mass: 1.3 };
export default function BrowseView({ cards, favorites, setFavorites, onShare }) {
const reduce = useReducedMotion();
const [query, setQuery] = useState('');
const [favOnly, setFavOnly] = useState(false);
const [openId, setOpenId] = useState(null);
const [toast, setToast] = useState('');
const overlayBtnRef = useRef(null);
const sentinelRef = useRef(null);
const [visibleCount, setVisibleCount] = useState(16);
const results = useMemo(() => {
let base = searchCards(query, cards);
if (favOnly) base = base.filter((c) => favorites.includes(c.id));
return base;
}, [query, favOnly, favorites, cards]);
const openCard = openId ? cardById(openId) : null;
const toggleFav = useCallback(
(id) => {
setFavorites((prev) => {
const has = prev.includes(id);
const next = has ? prev.filter((i) => i !== id) : [...prev, id];
storage.setFavorites(next);
return next;
});
},
[setFavorites]
);
// incremental rendering: filter restarts from the top, sentinel bumps the
// window as the reader scrolls. Keeps mobile browse from mounting all 187
// cards at once.
useEffect(() => {
setVisibleCount(16);
}, [query, favOnly]);
useEffect(() => {
if (!sentinelRef.current) return undefined;
const obs = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
setVisibleCount((n) => n + 16);
}
},
{ rootMargin: '240px' }
);
obs.observe(sentinelRef.current);
return () => obs.disconnect();
}, [visibleCount, results.length]);
// overlay keyboard: focus in on open, Escape closes.
useEffect(() => {
if (!openId) return undefined;
overlayBtnRef.current?.focus();
const onKey = (e) => {
if (e.key === 'Escape') setOpenId(null);
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [openId]);
const onShareCard = useCallback(
(id) => {
onShare?.(id);
setToast('copied');
window.setTimeout(() => setToast(''), 1400);
},
[onShare]
);
// Browse grid cards: compact, keyboard-accessible. Enter opens the overlay.
const onKeyDownCard = (e, id) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setOpenId(id);
}
};
return (
<div className={styles.browse}>
<div className={styles.browseBar}>
<input
className={styles.searchInput}
type="search"
placeholder="chercher une stratégie…"
value={query}
onChange={(e) => setQuery(e.target.value)}
aria-label="Rechercher"
spellCheck={false}
autoComplete="off"
/>
<span className={styles.matchCount}>
187 {results.length.toString().padStart(3, '0')}
</span>
<button
type="button"
className={`${styles.pill} ${favOnly ? styles.pillOn : ''}`}
onClick={() => setFavOnly((v) => !v)}
aria-pressed={favOnly}
>
favoris
</button>
</div>
{results.length === 0 ? (
<p className={styles.browseEmpty}>aucune carte ne correspond.</p>
) : (
<>
<div className={styles.grid}>
{results.slice(0, visibleCount).map((c) => {
const fav = favorites.includes(c.id);
return (
<button
key={c.id}
type="button"
className={styles.gridCard}
onClick={() => setOpenId(c.id)}
onKeyDown={(e) => onKeyDownCard(e, c.id)}
aria-label={`Ouvrir : ${c.text}`}
>
<ObliqueCard
card={c}
faceUp
favorited={fav}
variant="browse"
/>
</button>
);
})}
</div>
{visibleCount < results.length && (
<div ref={sentinelRef} className={styles.sentinel}>
scroll for more · {results.length - visibleCount} remaining
</div>
)}
</>
)}
{toast && <span className={styles.toast}>{toast}</span>}
{/* focused overlay */}
<AnimatePresence>
{openCard && (
<motion.div
className={styles.overlay}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
onClick={() => setOpenId(null)}
role="dialog"
aria-modal="true"
aria-label={openCard.text}
>
<motion.div
className={styles.overlayCard}
initial={{ scale: 0.86, y: 24, rotateZ: -1.5 }}
animate={{ scale: 1, y: 0, rotateZ: -1.5 }}
exit={{ scale: 0.86, y: 24, opacity: 0 }}
transition={reduce ? { duration: 0.18 } : FLIP_SPRING}
onClick={(e) => e.stopPropagation()}
>
<ObliqueCard
card={openCard}
faceUp
favorited={favorites.includes(openCard.id)}
onToggleFav={() => toggleFav(openCard.id)}
variant="draw"
/>
<div className={styles.overlayActions}>
<button
ref={overlayBtnRef}
type="button"
className={styles.ghostBtn}
onClick={() => onShareCard(openCard.id)}
>
share
</button>
<button
type="button"
className={styles.ghostBtn}
onClick={() => setOpenId(null)}
>
close
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
'use client';
/* DRAW mode — the ritual.
Deal a card (random from the no-repeat queue, or the daily card on first
load of the day). Reveal via 3D flip with a chilled spring. Drag gestures:
← discard + deal next, → favorite + deal next, ↑ stash to kept-spread + deal
next. On commit, a tiny haptic. Kept-spread grows as you stash. */
import { useState, useCallback, useEffect } from 'react';
import { motion, AnimatePresence, useReducedMotion } from 'framer-motion';
import ObliqueCard from './ObliqueCard';
import { cardById, drawFromQueue, storage } from '@/lib/oblique';
import styles from '@/styles/oblique.module.css';
const SWIPE_THRESHOLD = 120;
function vibrate(ms) {
if (typeof navigator !== 'undefined' && 'vibrate' in navigator) {
try {
navigator.vibrate(ms);
} catch {
/* no-op */
}
}
}
export default function DrawView({
cards,
favorites,
setFavorites,
kept,
setKept,
initialCardId,
dailyCaption,
onShare,
}) {
const reduce = useReducedMotion();
const [currentId, setCurrentId] = useState(() => initialCardId ?? storage.getLast());
const [faceUp, setFaceUp] = useState(false); // start face-down, flip in
const [exitDir, setExitDir] = useState(null); // 'left'|'right'|'up'|null
const [toast, setToast] = useState('');
const card = cardById(currentId) || cards[0];
const favorited = favorites.includes(card.id);
// First mount: if we were handed a daily/shared card, show it; otherwise the
// queue's current state. Either way we flip in.
useEffect(() => {
setFaceUp(false);
const t = setTimeout(() => setFaceUp(true), 120);
return () => clearTimeout(t);
}, [currentId]);
const dealNext = useCallback(
(opts = {}) => {
const excludeId = opts.exclude ?? currentId;
const nextId = drawFromQueue(excludeId);
setExitDir(opts.exit || null);
// After the out-animation window, swap the card. The [currentId] effect
// below drives the face-down → flip-in for the new card; exitDir drives
// the outgoing card's fly-off via AnimatePresence.
window.setTimeout(() => {
setCurrentId(nextId);
setExitDir(null);
}, opts.exit ? 260 : 0);
},
[currentId]
);
// redo: just deal another without swipe
const onRedo = useCallback(() => {
vibrate(8);
dealNext({ exclude: currentId });
}, [dealNext, currentId]);
const toggleFav = useCallback(() => {
setFavorites((prev) => {
const has = prev.includes(card.id);
const next = has ? prev.filter((i) => i !== card.id) : [...prev, card.id];
storage.setFavorites(next);
return next;
});
}, [card, setFavorites]);
// Drag end → snap to a direction or spring back.
const onDragEnd = useCallback(
(e, info) => {
const { x, y } = info.offset;
if (x < -SWIPE_THRESHOLD) {
vibrate(12);
dealNext({ exit: 'left', exclude: card.id });
} else if (x > SWIPE_THRESHOLD) {
vibrate(12);
// favorite + deal next
setFavorites((prev) => {
const next = prev.includes(card.id) ? prev : [...prev, card.id];
storage.setFavorites(next);
return next;
});
dealNext({ exit: 'right', exclude: card.id });
} else if (y < -SWIPE_THRESHOLD) {
vibrate(12);
setKept((prev) => {
const next = prev.includes(card.id) ? prev : [...prev, card.id];
storage.setKept(next);
return next;
});
dealNext({ exit: 'up', exclude: card.id });
}
},
[card, dealNext, setFavorites, setKept]
);
const exitVariants = {
left: { x: '-130vw', opacity: 0, transition: { duration: 0.28, ease: 'easeIn' } },
right: { x: '130vw', opacity: 0, transition: { duration: 0.28, ease: 'easeIn' } },
up: { y: '-130vh', opacity: 0, transition: { duration: 0.28, ease: 'easeIn' } },
};
const onShareCard = useCallback(() => {
onShare?.(card.id);
setToast('copied');
window.setTimeout(() => setToast(''), 1400);
}, [card, onShare]);
const onRemoveKept = useCallback(
(id) => {
setKept((prev) => {
const next = prev.filter((i) => i !== id);
storage.setKept(next);
return next;
});
},
[setKept]
);
const onViewKept = useCallback((id) => {
// swapping id triggers the [currentId] effect → flip-in
setCurrentId(id);
}, []);
return (
<div className={styles.draw}>
<div className={styles.drawStage}>
{dailyCaption && <p className={styles.dailyCaption}> {dailyCaption}</p>}
<AnimatePresence mode="wait" custom={exitDir}>
<motion.div
key={currentId}
className={styles.drawCardWrap}
custom={exitDir}
drag={!reduce}
dragConstraints={{ left: 0, right: 0, top: 0, bottom: 0 }}
dragElastic={0.7}
onDragEnd={reduce ? undefined : onDragEnd}
exit={exitDir ? exitVariants[exitDir] : { opacity: 0, scale: 0.94 }}
initial={{ opacity: 0, scale: 0.94 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.24, ease: 'easeOut' }}
whileTap={reduce ? undefined : { cursor: 'grabbing' }}
>
{/* directional drag hints that brighten with drag */}
<span className={`${styles.swipeHint} ${styles.swipeLeft}`}> new</span>
<span className={`${styles.swipeHint} ${styles.swipeRight}`}> fav</span>
<span className={`${styles.swipeHint} ${styles.swipeUp}`}> keep</span>
<ObliqueCard
card={card}
faceUp={faceUp}
favorited={favorited}
onToggleFav={toggleFav}
variant="draw"
/>
</motion.div>
</AnimatePresence>
<div className={styles.drawActions}>
<button type="button" className={styles.ghostBtn} onClick={onRedo} aria-label="Repiocher">
redo
</button>
<button type="button" className={styles.ghostBtn} onClick={onShareCard} aria-label="Partager">
share
</button>
</div>
<p className={styles.drawHint}>
glisse nouveau · favori · garde
</p>
{toast && <span className={styles.toast}>{toast}</span>}
</div>
{/* kept-spread: stashed cards, horizontal scroll */}
<div className={styles.kept}>
<p className={styles.keptHeader}>
kept · {kept.length.toString().padStart(2, '0')}
</p>
{kept.length === 0 ? (
<p className={styles.keptEmpty}>glisse pour garder une carte</p>
) : (
<div className={styles.keptRow}>
{kept.map((id) => {
const c = cardById(id);
if (!c) return null;
return (
<div key={id} className={styles.keptChip}>
<button
type="button"
className={styles.keptChipBody}
onClick={() => onViewKept(id)}
aria-label={`Revoir : ${c.text}`}
>
<span className={styles.keptChipHex}>{c.hex}</span>
<span className={styles.keptChipText}>{c.text}</span>
</button>
<button
type="button"
className={styles.keptRemove}
onClick={() => onRemoveKept(id)}
aria-label="Retirer"
>
</button>
</div>
);
})}
</div>
)}
</div>
</div>
);
}
'use client';
/* The card: frosted glass, iridescent foil edge, a per-card sacred-geometry
co-star (see SacredGeometry — the figure is derived from the card's id and
hexagram, so no two of the 187 wear the same one), I Ching hexagram
filigree, ◆ favorite indicator. Supports a 3D tarot flip
(face-down → face-up) and a tighter static face used in the browse grid.
Flip is CHILLED: spring stiffness 42 / damping 22 / mass 1.3 — the lab's
120/14 was too bouncy, so this lands soft. Lands with a -1.5deg roll. Under
prefers-reduced-motion the flip becomes a fade (function intact). */
import { motion, useReducedMotion } from 'framer-motion';
import SacredGeometry from './SacredGeometry';
import { ordinalOf } from '@/lib/oblique';
import styles from '@/styles/oblique.module.css';
const FLIP_SPRING = { type: 'spring', stiffness: 42, damping: 22, mass: 1.3 };
// Card back: the card's own figure at higher opacity and unmasked (no text to
// protect) + a large faint hexagram, so the back reads as a real tarot back,
// not a blank.
function CardBack({ card }) {
return (
<div className={styles.cardBack}>
<SacredGeometry
className={styles.cardBackFlower}
opacity={0.32}
strokeWidth={1.1}
seed={card.id}
hex={card.hex}
variant="back"
/>
<span className={styles.cardBackHex}>{card.hex}</span>
<span className={styles.cardBackMark}>oblique</span>
</div>
);
}
// Card face: hex filigree top-left, the card's figure behind the text — masked
// quiet in the middle so it never fights the words, and legible on top of that
// via a denser text patch + text-shadow halo — strategy text, favorite ◆.
function CardFace({ card, favorited, onToggleFav, showFavButton = true, onClick }) {
return (
<div className={styles.cardFace} onClick={onClick}>
<SacredGeometry
className={styles.cardFaceFlower}
opacity={0.22}
strokeWidth={0.9}
seed={card.id}
hex={card.hex}
variant="face"
/>
<span className={styles.cardHex} aria-hidden="true">{card.hex}</span>
<span className={styles.cardOrdinal}>{ordinalOf(card)}</span>
<div className={styles.cardTextPatch}>
<p className={styles.cardText}>{card.text}</p>
</div>
{showFavButton && (
<button
type="button"
className={`${styles.favBtn} ${favorited ? styles.favOn : ''}`}
onClick={(e) => {
e.stopPropagation();
onToggleFav?.();
}}
aria-pressed={favorited}
aria-label={favorited ? 'Retirer des favoris' : 'Ajouter aux favoris'}
>
</button>
)}
{!showFavButton && favorited && (
<span className={`${styles.favMark} ${styles.favOn}`} aria-label="favori"></span>
)}
</div>
);
}
/**
* ObliqueCard
* @param {object} card the strategy card
* @param {boolean} faceUp initial face state
* @param {boolean} favorited
* @param {function} onToggleFav
* @param {boolean} animate wrap flip in AnimatePresence (draw-mode reveal)
* @param {string} variant 'draw' | 'browse' (compact)
*/
export default function ObliqueCard({
card,
faceUp = true,
favorited = false,
onToggleFav,
variant = 'draw',
onClick,
}) {
const reduce = useReducedMotion();
const isDraw = variant === 'draw';
// For reduced-motion or browse variant we skip the 3D flip and fade/none.
if (!isDraw || reduce) {
return (
<div className={`${styles.card} ${isDraw ? styles.cardDraw : styles.cardBrowse}`}>
<CardFace
card={card}
favorited={favorited}
onToggleFav={onToggleFav}
showFavButton={isDraw}
onClick={onClick}
/>
<span className={styles.foilEdge} aria-hidden="true" />
</div>
);
}
// 3D flip reveal — preserve-3d so back and front share the same space.
return (
<div className={`${styles.card} ${styles.cardDraw} ${styles.card3d}`}>
<motion.div
className={styles.cardInner}
initial={{ rotateY: 180 }}
animate={{ rotateY: faceUp ? 0 : 180 }}
transition={FLIP_SPRING}
style={{ transformStyle: 'preserve-3d' }}
onAnimationComplete={() => {
// settle the gentle resting tilt once the flip lands
}}
>
{/* settled tilt applied to the face-up state via CSS animation hook */}
<div
className={`${styles.cardHalf} ${styles.cardHalfBack}`}
style={{ backfaceVisibility: 'hidden', transform: 'rotateY(180deg)' }}
>
<CardBack card={card} />
</div>
<div
className={`${styles.cardHalf} ${styles.cardHalfFront} ${faceUp ? styles.tilted : ''}`}
style={{ backfaceVisibility: 'hidden' }}
>
<CardFace
card={card}
favorited={favorited}
onToggleFav={onToggleFav}
showFavButton
/>
<span className={styles.foilEdge} aria-hidden="true" />
</div>
</motion.div>
</div>
);
}
'use client';
/* Per-card sacred geometry — a deterministic composition, one per strategy.
Every card used to wear the same 19-circle Flower of Life; now each of the
187 gets its own figure, derived only from its id and its I Ching hexagram.
Same card, same figure, forever — and identical between the prerendered HTML
and the hydrated client, which is why there is no Math.random anywhere here.
The figure is a co-star, never the lead: a radial mask keeps the middle of
the card quiet so the strategy text always wins. See QUIET_* below.
`opacity` is applied via inline style on the <svg> so the same component can
go from ~22% (card face, legible) down to ~6% (page ambient). */
import { useMemo } from 'react';
import styles from '@/styles/oblique.module.css';
// mulberry32 — 32-bit seeded PRNG, ~10 lines and good enough for picking
// parameters. Returns a function yielding floats in [0, 1).
function mulberry32(a) {
let t = a >>> 0;
return function next() {
t = (t + 0x6d2b79f5) >>> 0;
let x = t;
x = Math.imul(x ^ (x >>> 15), x | 1);
x ^= x + Math.imul(x ^ (x >>> 7), x | 61);
return ((x ^ (x >>> 14)) >>> 0) / 4294967296;
};
}
// The viewBox half-span is fixed for every card, whatever the ring count, so
// the pattern always carries the same visual weight inside the card frame.
const SPAN = 120;
const VB = 126;
/* Quiet zone. The face SVG is sized at 120% of the card with inset -10%, so the
card's own edge sits at 1/1.2 ≈ 0.833 of the SVG half-span. To keep the
central ~55% of the CARD quiet we mask out to 0.55 × 0.833 ≈ 0.46 of the
SVG half-span — these two numbers are in different frames on purpose.
The mask floor is a grey, not black: a whisper of pattern survives under the
text, which reads as depth where a hole would read as a mistake. */
const QUIET_INNER = 0.46; // fraction of the SVG half-span held at the floor
const QUIET_OUTER = 0.66; // fraction where the pattern reaches full strength
const QUIET_FLOOR = 0.28; // ≤ 0.30 of edge strength, per the legibility brief
/* Ring emphasis. The baseline sits just under full so the emphasized ring has
somewhere to go, but close enough that the edges keep the strength the
single-flower version had at opacity 0.22. */
const STROKE_BASE = 0.86;
const STROKE_EMPH = 1;
const DASHES = ['3 5', '1 4', '6 4', '2 3 8 3'];
const r2 = (n) => Math.round(n * 100) / 100;
// Vertices of a regular n-gon (or of a {n/skip} star polygon) as an SVG path.
function polygonPath(n, radius, phase, skip = 1) {
const pts = [];
const order = [];
let i = 0;
do {
order.push(i);
i = (i + skip) % n;
} while (i !== 0 && order.length < n);
for (const k of order) {
const a = phase + (2 * Math.PI * k) / n;
pts.push(`${r2(radius * Math.cos(a))},${r2(radius * Math.sin(a))}`);
}
return `M${pts.join('L')}Z`;
}
/* Fold the card id and its hexagram into a figure. The two are correlated (the
deck assigns hexagrams in order) but mixing both gives the codepoint a real
say in rotation and in which overlay shows up. */
function composition(seed, hex) {
const cp = typeof hex === 'string' && hex.length ? hex.codePointAt(0) : 0x4dc0;
const rand = mulberry32((seed >>> 0) * 2654435761 + cp);
const rings = 1 + Math.floor(rand() * 3); // 1..3 concentric rings
// Circles per ring: pentagonal through enneagonal packing. Rings after the
// first may step up by one, which is what makes the lattices interlock oddly.
const counts = [];
const base = 5 + Math.floor(rand() * 5); // 5..9
for (let k = 0; k < rings; k += 1) {
counts.push(Math.min(9, base + (rand() < 0.45 ? k : 0)));
}
const radiusRatio = 0.62 + rand() * 0.38; // circle radius vs ring spacing
const spacing = SPAN / (rings + radiusRatio);
// Rotation: the hexagram picks a 12th-turn phase, the PRNG nudges it off-grid.
const rot = (cp % 12) * (Math.PI / 6) + (rand() - 0.5) * (Math.PI / 9);
const ringTwist = (rand() - 0.5) * (Math.PI / 7); // per-ring counter-rotation
const emphRing = Math.floor(rand() * (rings + 1)) - 1; // -1 = the centre circle
const dashRing = rand() < 0.55 ? Math.floor(rand() * rings) : -1;
const dash = DASHES[Math.floor(rand() * DASHES.length)];
// Overlaid polygon: a triangle, hexagon or enneagram star, or nothing at all.
const overlayRoll = rand();
let overlay = null;
if (overlayRoll < 0.72) {
const kind = (cp + Math.floor(rand() * 3)) % 3;
const spec =
kind === 0
? { n: 3, skip: 1 }
: kind === 1
? { n: 6, skip: 1 }
: { n: 9, skip: 4 }; // {9/4} enneagram
overlay = {
...spec,
radius: spacing * (rings - 0.15 + rand() * 0.5),
phase: rot + (rand() - 0.5) * (Math.PI / 5),
};
}
const circles = [];
circles.push({ cx: 0, cy: 0, r: r2(spacing * radiusRatio), ring: -1 });
for (let k = 1; k <= rings; k += 1) {
const count = counts[k - 1];
const dist = spacing * k;
const phase = rot + ringTwist * k;
// Outer rings thin their circles slightly so the figure breathes outward.
const rad = spacing * radiusRatio * (1 - 0.08 * (k - 1));
for (let i = 0; i < count; i += 1) {
const a = phase + (2 * Math.PI * i) / count;
circles.push({
cx: r2(dist * Math.cos(a)),
cy: r2(dist * Math.sin(a)),
r: r2(rad),
ring: k - 1,
});
}
}
return { circles, overlay, emphRing, dashRing, dash };
}
/**
* SacredGeometry
* @param {number} seed card id (1..187) — the whole figure hangs off this
* @param {string} hex the card's I Ching hexagram glyph
* @param {string} variant 'face' (quiet centre, text lives there) | 'back'
* (unmasked — nothing to protect). Anything else is
* treated as a face, so a future ambient/divider use
* gets the quiet centre by default.
*/
export default function SacredGeometry({
className = '',
opacity = 0.22,
strokeWidth = 1,
seed = 0,
hex = '',
variant = 'face',
}) {
const fig = useMemo(() => composition(seed, hex), [seed, hex]);
// The back carries no text, so it goes unmasked and shows its overlay always.
const quiet = variant !== 'back';
const uid = `${seed}-${variant}`;
const gradId = `obq-grad-${uid}`;
const maskId = `obq-mask-${uid}`;
const byRing = new Map();
for (const c of fig.circles) {
if (!byRing.has(c.ring)) byRing.set(c.ring, []);
byRing.get(c.ring).push(c);
}
return (
<svg
className={`${styles.flower} ${className}`}
viewBox={`${-VB} ${-VB} ${2 * VB} ${2 * VB}`}
fill="none"
stroke="currentColor"
strokeWidth={strokeWidth}
aria-hidden="true"
style={{ opacity }}
>
{quiet && (
<defs>
{/* Greyscale mask: black hides, white shows. Radius 50% of the rect
equals the viewBox half-span, so the stop offsets below read
directly as fractions of that half-span. */}
<radialGradient id={gradId}>
<stop offset="0%" stopColor="#fff" stopOpacity={QUIET_FLOOR} />
<stop offset={`${QUIET_INNER * 100}%`} stopColor="#fff" stopOpacity={QUIET_FLOOR} />
<stop offset={`${QUIET_OUTER * 100}%`} stopColor="#fff" stopOpacity="1" />
<stop offset="100%" stopColor="#fff" stopOpacity="1" />
</radialGradient>
<mask id={maskId} maskUnits="userSpaceOnUse" x={-VB} y={-VB} width={2 * VB} height={2 * VB}>
<rect x={-VB} y={-VB} width={2 * VB} height={2 * VB} fill={`url(#${gradId})`} />
</mask>
</defs>
)}
<g mask={quiet ? `url(#${maskId})` : undefined}>
{[...byRing.entries()].map(([ring, list]) => (
<g
key={ring}
strokeOpacity={ring === fig.emphRing ? STROKE_EMPH : STROKE_BASE}
strokeDasharray={ring === fig.dashRing ? fig.dash : undefined}
>
{list.map((c, i) => (
<circle key={i} cx={c.cx} cy={c.cy} r={c.r} />
))}
</g>
))}
{fig.overlay && (
<path
d={polygonPath(fig.overlay.n, fig.overlay.radius, fig.overlay.phase, fig.overlay.skip)}
strokeOpacity={variant === 'back' ? 0.8 : 0.55}
/>
)}
</g>
</svg>
);
}
{
"count": 187,
"source": "ceejbot/oblique-strategies (cross-edition superset, deduped)",
"cards": [
{
"id": 1,
"text": "(Organic) machinery",
"hex": "䷀"
},
{
"id": 2,
"text": "A line has two sides",
"hex": "䷁"
},
{
"id": 3,
"text": "A very small object Its center",
"hex": "䷂"
},
{
"id": 4,
"text": "Abandon desire",
"hex": "䷃"
},
{
"id": 5,
"text": "Abandon normal instructions",
"hex": "䷄"
},
{
"id": 6,
"text": "Abandon normal instruments",
"hex": "䷅"
},
{
"id": 7,
"text": "Accept advice",
"hex": "䷆"
},
{
"id": 8,
"text": "Accretion",
"hex": "䷇"
},
{
"id": 9,
"text": "Adding on",
"hex": "䷈"
},
{
"id": 10,
"text": "Allow an easement (an easement is the abandonment of a stricture)",
"hex": "䷉"
},
{
"id": 11,
"text": "Always first steps",
"hex": "䷊"
},
{
"id": 12,
"text": "Always give yourself credit for having more than personality (given by Arto Lindsay)",
"hex": "䷋"
},
{
"id": 13,
"text": "Always the first steps",
"hex": "䷌"
},
{
"id": 14,
"text": "Are there sections? Consider transitions",
"hex": "䷍"
},
{
"id": 15,
"text": "Ask people to work against their better judgement",
"hex": "䷎"
},
{
"id": 16,
"text": "Ask your body",
"hex": "䷏"
},
{
"id": 17,
"text": "Assemble some of the elements in a group and treat the group",
"hex": "䷐"
},
{
"id": 18,
"text": "Balance the consistency principle with the inconsistency principle",
"hex": "䷑"
},
{
"id": 19,
"text": "Be dirty",
"hex": "䷒"
},
{
"id": 20,
"text": "Be extravagant",
"hex": "䷓"
},
{
"id": 21,
"text": "Be less critical",
"hex": "䷔"
},
{
"id": 22,
"text": "Breathe more deeply",
"hex": "䷕"
},
{
"id": 23,
"text": "Bridges -build -burn",
"hex": "䷖"
},
{
"id": 24,
"text": "Cascades",
"hex": "䷗"
},
{
"id": 25,
"text": "Change ambiguities to specifics",
"hex": "䷘"
},
{
"id": 26,
"text": "Change instrument roles",
"hex": "䷙"
},
{
"id": 27,
"text": "Change nothing and continue consistently",
"hex": "䷚"
},
{
"id": 28,
"text": "Change nothing and continue with immaculate consistency",
"hex": "䷛"
},
{
"id": 29,
"text": "Change specifics to ambiguities",
"hex": "䷜"
},
{
"id": 30,
"text": "Children -speaking -singing",
"hex": "䷝"
},
{
"id": 31,
"text": "Cluster analysis",
"hex": "䷞"
},
{
"id": 32,
"text": "Consider different fading systems",
"hex": "䷟"
},
{
"id": 33,
"text": "Consider transitions",
"hex": "䷠"
},
{
"id": 34,
"text": "Consult other sources -promising -unpromising",
"hex": "䷡"
},
{
"id": 35,
"text": "Convert a melodic element into a rhythmic element",
"hex": "䷢"
},
{
"id": 36,
"text": "Courage!",
"hex": "䷣"
},
{
"id": 37,
"text": "Cut a vital conenction",
"hex": "䷤"
},
{
"id": 38,
"text": "Cut a vital connection",
"hex": "䷥"
},
{
"id": 39,
"text": "Decorate, decorate",
"hex": "䷦"
},
{
"id": 40,
"text": "Define an area as `safe' and use it as an anchor",
"hex": "䷧"
},
{
"id": 41,
"text": "Destroy -nothing -the most important thing",
"hex": "䷨"
},
{
"id": 42,
"text": "Destroy nothing; Destroy the most important thing",
"hex": "䷩"
},
{
"id": 43,
"text": "Discard an axiom",
"hex": "䷪"
},
{
"id": 44,
"text": "Disciplined self-indulgence",
"hex": "䷫"
},
{
"id": 45,
"text": "Disconnect from desire",
"hex": "䷬"
},
{
"id": 46,
"text": "Discover the recipes you are using and abandon them",
"hex": "䷭"
},
{
"id": 47,
"text": "Discover your formulas and abandon them",
"hex": "䷮"
},
{
"id": 48,
"text": "Display your talent",
"hex": "䷯"
},
{
"id": 49,
"text": "Distort time",
"hex": "䷰"
},
{
"id": 50,
"text": "Distorting time",
"hex": "䷱"
},
{
"id": 51,
"text": "Do nothing for as long as possible",
"hex": "䷲"
},
{
"id": 52,
"text": "Do something boring",
"hex": "䷳"
},
{
"id": 53,
"text": "Do something sudden, destructive and unpredictable",
"hex": "䷴"
},
{
"id": 54,
"text": "Do the last thing first",
"hex": "䷵"
},
{
"id": 55,
"text": "Do the washing up",
"hex": "䷶"
},
{
"id": 56,
"text": "Do the words need changing?",
"hex": "䷷"
},
{
"id": 57,
"text": "Do we need holes?",
"hex": "䷸"
},
{
"id": 58,
"text": "Don't avoid what is easy",
"hex": "䷹"
},
{
"id": 59,
"text": "Don't be frightened of cliches",
"hex": "䷺"
},
{
"id": 60,
"text": "Don't break the silence",
"hex": "䷻"
},
{
"id": 61,
"text": "Don't stress on thing more than another [sic]",
"hex": "䷼"
},
{
"id": 62,
"text": "Don't stress one thing more than another",
"hex": "䷽"
},
{
"id": 63,
"text": "Dont be afraid of things because they're easy to do",
"hex": "䷾"
},
{
"id": 64,
"text": "Dont be frightened to display your talents",
"hex": "䷿"
},
{
"id": 65,
"text": "Emphasize differences",
"hex": "䷀"
},
{
"id": 66,
"text": "Emphasize repetitions",
"hex": "䷁"
},
{
"id": 67,
"text": "Emphasize the flaws",
"hex": "䷂"
},
{
"id": 68,
"text": "Faced with a choice, do both (from Dieter Rot)",
"hex": "䷃"
},
{
"id": 69,
"text": "Faced with a choice, do both (given by Dieter Rot)",
"hex": "䷄"
},
{
"id": 70,
"text": "Feed the recording back out of the medium",
"hex": "䷅"
},
{
"id": 71,
"text": "Fill every beat with something",
"hex": "䷆"
},
{
"id": 72,
"text": "Find a safe part and use it as an anchor",
"hex": "䷇"
},
{
"id": 73,
"text": "Get your neck massaged",
"hex": "䷈"
},
{
"id": 74,
"text": "Ghost echoes",
"hex": "䷉"
},
{
"id": 75,
"text": "Give the game away",
"hex": "䷊"
},
{
"id": 76,
"text": "Give the name away",
"hex": "䷋"
},
{
"id": 77,
"text": "Give way to your worst impulse",
"hex": "䷌"
},
{
"id": 78,
"text": "Go outside. Shut the door.",
"hex": "䷍"
},
{
"id": 79,
"text": "Go slowly all the way round the outside",
"hex": "䷎"
},
{
"id": 80,
"text": "Go to an extreme, come part way back",
"hex": "䷏"
},
{
"id": 81,
"text": "Honor thy error as a hidden intention",
"hex": "䷐"
},
{
"id": 82,
"text": "Honor thy mistake as a hidden intention",
"hex": "䷑"
},
{
"id": 83,
"text": "How would someone else do it?",
"hex": "䷒"
},
{
"id": 84,
"text": "How would you have done it?",
"hex": "䷓"
},
{
"id": 85,
"text": "Humanize something free of error",
"hex": "䷔"
},
{
"id": 86,
"text": "Idiot glee (?)",
"hex": "䷕"
},
{
"id": 87,
"text": "Imagine the piece as a set of disconnected events",
"hex": "䷖"
},
{
"id": 88,
"text": "In total darkness, or in a very large room, very quietly",
"hex": "䷗"
},
{
"id": 89,
"text": "Infinitesimal gradations",
"hex": "䷘"
},
{
"id": 90,
"text": "Intentions -nobility of -humility of -credibility of",
"hex": "䷙"
},
{
"id": 91,
"text": "Into the impossible",
"hex": "䷚"
},
{
"id": 92,
"text": "Is it finished?",
"hex": "䷛"
},
{
"id": 93,
"text": "Is something missing?",
"hex": "䷜"
},
{
"id": 94,
"text": "Is the information correct?",
"hex": "䷝"
},
{
"id": 95,
"text": "Is the style right?",
"hex": "䷞"
},
{
"id": 96,
"text": "Is there something missing",
"hex": "䷟"
},
{
"id": 97,
"text": "It is quite possible (after all)",
"hex": "䷠"
},
{
"id": 98,
"text": "It is simply a matter or work",
"hex": "䷡"
},
{
"id": 99,
"text": "Just carry on",
"hex": "䷢"
},
{
"id": 100,
"text": "Left channel, right channel, center channel",
"hex": "䷣"
},
{
"id": 101,
"text": "Listen to the quiet voice",
"hex": "䷤"
},
{
"id": 102,
"text": "Look at the order in which you do things",
"hex": "䷥"
},
{
"id": 103,
"text": "Look closely at the most embarrassing details & amplify them",
"hex": "䷦"
},
{
"id": 104,
"text": "Lost in useless territory",
"hex": "䷧"
},
{
"id": 105,
"text": "Lowest common denominator",
"hex": "䷨"
},
{
"id": 106,
"text": "Magnify the most difficult details",
"hex": "䷩"
},
{
"id": 107,
"text": "Make a blank valuable by putting it in an exquisite frame",
"hex": "䷪"
},
{
"id": 108,
"text": "Make a sudden, destructive unpredictable action; incorporate",
"hex": "䷫"
},
{
"id": 109,
"text": "Make an exhaustive list of everything you might do & do the last thing on the list",
"hex": "䷬"
},
{
"id": 110,
"text": "Make it more sensual",
"hex": "䷭"
},
{
"id": 111,
"text": "Make what's perfect more human",
"hex": "䷮"
},
{
"id": 112,
"text": "Mechanicalize something idiosyncratic",
"hex": "䷯"
},
{
"id": 113,
"text": "Move towards the unimportant",
"hex": "䷰"
},
{
"id": 114,
"text": "Mute and continue",
"hex": "䷱"
},
{
"id": 115,
"text": "Not building a wall but making a brick",
"hex": "䷲"
},
{
"id": 116,
"text": "Not building a wall; making a brick",
"hex": "䷳"
},
{
"id": 117,
"text": "Once the search has begun, something will be found",
"hex": "䷴"
},
{
"id": 118,
"text": "Only a part, not the whole",
"hex": "䷵"
},
{
"id": 119,
"text": "Only one element of each kind",
"hex": "䷶"
},
{
"id": 120,
"text": "Openly resist change",
"hex": "䷷"
},
{
"id": 121,
"text": "Overtly resist change",
"hex": "䷸"
},
{
"id": 122,
"text": "Pae White's non-blank graphic metacard",
"hex": "䷹"
},
{
"id": 123,
"text": "Put in earplugs",
"hex": "䷺"
},
{
"id": 124,
"text": "Question the heroic",
"hex": "䷻"
},
{
"id": 125,
"text": "Question the heroic approach",
"hex": "䷼"
},
{
"id": 126,
"text": "Reevaluation (a warm feeling)",
"hex": "䷽"
},
{
"id": 127,
"text": "Remember quiet evenings",
"hex": "䷾"
},
{
"id": 128,
"text": "Remember those quiet evenings",
"hex": "䷿"
},
{
"id": 129,
"text": "Remove a restriction",
"hex": "䷀"
},
{
"id": 130,
"text": "Remove ambiguities and convert to specifics",
"hex": "䷁"
},
{
"id": 131,
"text": "Remove specifics and convert to ambiguities",
"hex": "䷂"
},
{
"id": 132,
"text": "Repetition is a form of change",
"hex": "䷃"
},
{
"id": 133,
"text": "Retrace your steps",
"hex": "䷄"
},
{
"id": 134,
"text": "Reverse",
"hex": "䷅"
},
{
"id": 135,
"text": "Short circuit (example; a man eating peas with the idea that they will improve his virility shovels them straight into his lap)",
"hex": "䷆"
},
{
"id": 136,
"text": "Simple Subtraction",
"hex": "䷇"
},
{
"id": 137,
"text": "Simply a matter of work",
"hex": "䷈"
},
{
"id": 138,
"text": "Slow preparation, fast execution",
"hex": "䷉"
},
{
"id": 139,
"text": "Spectrum analysis",
"hex": "䷊"
},
{
"id": 140,
"text": "State the problem as clearly as possible",
"hex": "䷋"
},
{
"id": 141,
"text": "State the problem in words as clearly as possible",
"hex": "䷌"
},
{
"id": 142,
"text": "Take a break",
"hex": "䷍"
},
{
"id": 143,
"text": "Take away the elements in order of apparent non-importance",
"hex": "䷎"
},
{
"id": 144,
"text": "Take away the important parts",
"hex": "䷏"
},
{
"id": 145,
"text": "Tape your mouth (given by Ritva Saarikko)",
"hex": "䷐"
},
{
"id": 146,
"text": "The inconsistency principle",
"hex": "䷑"
},
{
"id": 147,
"text": "The most easily forgotten thing is the most important",
"hex": "䷒"
},
{
"id": 148,
"text": "The most important thing is the thing most easily forgotten",
"hex": "䷓"
},
{
"id": 149,
"text": "The tape is now the music",
"hex": "䷔"
},
{
"id": 150,
"text": "Think - inside the work -outside the work",
"hex": "䷕"
},
{
"id": 151,
"text": "Think of the radio",
"hex": "䷖"
},
{
"id": 152,
"text": "Tidy up",
"hex": "䷗"
},
{
"id": 153,
"text": "Towards the insignificant",
"hex": "䷘"
},
{
"id": 154,
"text": "Trust in the you of now",
"hex": "䷙"
},
{
"id": 155,
"text": "Try faking it (from Stewart Brand)",
"hex": "䷚"
},
{
"id": 156,
"text": "Turn it upside down",
"hex": "䷛"
},
{
"id": 157,
"text": "Twist the spine",
"hex": "䷜"
},
{
"id": 158,
"text": "Use 'unqualified' people",
"hex": "䷝"
},
{
"id": 159,
"text": "Use `unqualified' people",
"hex": "䷞"
},
{
"id": 160,
"text": "Use an old idea",
"hex": "䷟"
},
{
"id": 161,
"text": "Use an unacceptable color",
"hex": "䷠"
},
{
"id": 162,
"text": "Use cliches",
"hex": "䷡"
},
{
"id": 163,
"text": "Use fewer notes",
"hex": "䷢"
},
{
"id": 164,
"text": "Use filters",
"hex": "䷣"
},
{
"id": 165,
"text": "Use something nearby as a model",
"hex": "䷤"
},
{
"id": 166,
"text": "Use your own ideas",
"hex": "䷥"
},
{
"id": 167,
"text": "Voice your suspicions",
"hex": "䷦"
},
{
"id": 168,
"text": "Water",
"hex": "䷧"
},
{
"id": 169,
"text": "What are the sections sections of? Imagine a caterpillar moving",
"hex": "䷨"
},
{
"id": 170,
"text": "What are you really thinking about just now?",
"hex": "䷩"
},
{
"id": 171,
"text": "What context would look right?",
"hex": "䷪"
},
{
"id": 172,
"text": "What is the reality of the situation?",
"hex": "䷫"
},
{
"id": 173,
"text": "What is the simplest solution?",
"hex": "䷬"
},
{
"id": 174,
"text": "What mistakes did you make last time?",
"hex": "䷭"
},
{
"id": 175,
"text": "What to increase? What to reduce? What to maintain?",
"hex": "䷮"
},
{
"id": 176,
"text": "What were you really thinking about just now?",
"hex": "䷯"
},
{
"id": 177,
"text": "What would your closest friend do?",
"hex": "䷰"
},
{
"id": 178,
"text": "What wouldn't you do?",
"hex": "䷱"
},
{
"id": 179,
"text": "When is it for?",
"hex": "䷲"
},
{
"id": 180,
"text": "Where is the edge?",
"hex": "䷳"
},
{
"id": 181,
"text": "Which parts can be grouped?",
"hex": "䷴"
},
{
"id": 182,
"text": "Work at a different speed",
"hex": "䷵"
},
{
"id": 183,
"text": "Would anyone want it?",
"hex": "䷶"
},
{
"id": 184,
"text": "You are an engineer",
"hex": "䷷"
},
{
"id": 185,
"text": "You can only make one dot at a time",
"hex": "䷸"
},
{
"id": 186,
"text": "You don't have to be ashamed of using your own ideas",
"hex": "䷹"
},
{
"id": 187,
"text": "[blank white card]",
"hex": "䷺"
}
]
}
\ No newline at end of file
/* oblique — data load, fuzzy search, daily card, localStorage, deal queue.
Pure helpers: no React, no window at module scope. All browser APIs are
called inside functions so SSR doesn't touch them. */
import strategies from '@/content/oblique/strategies.json';
export const CARDS = strategies.cards;
export const COUNT = strategies.count;
// Quick id -> card lookup. Cards are 1-indexed and dense, so a plain object
// over an array is the right shape for arbitrary-id lookups from URLs/storage.
const BY_ID = Object.create(null);
for (const c of CARDS) BY_ID[c.id] = c;
export function cardById(id) {
const n = Number(id);
if (!Number.isFinite(n)) return null;
return BY_ID[n] || null;
}
export function ordinalOf(card) {
return String(card.id).padStart(3, '0') + '/' + COUNT;
}
/* ── daily card ──
Deterministic from the date so everyone draws the same card on the same day,
and a given day always yields the same card. Seed folds y/m/d into one int. */
export function dailyCard(cards = CARDS) {
const now = new Date();
const seed = now.getFullYear() * 10000 + (now.getMonth() + 1) * 100 + now.getDate();
return cards[seed % cards.length];
}
export function todayString() {
const now = new Date();
const m = String(now.getMonth() + 1).padStart(2, '0');
const d = String(now.getDate()).padStart(2, '0');
return `${now.getFullYear()}-${m}-${d}`;
}
/* ── fuzzy search ──
Normalize (lowercase, strip diacritics) then match either by subsequence
(query chars appear in order) or plain includes. includes-matches rank
first because they're stronger hits. Empty query returns everything. */
export function normalize(s) {
return String(s)
.toLowerCase()
.normalize('NFD')
.replace(/[̀-ͯ]/g, '');
}
function isSubsequence(query, text) {
let i = 0;
for (let j = 0; j < text.length && i < query.length; j++) {
if (text[j] === query[i]) i++;
}
return i === query.length;
}
export function searchCards(query, cards = CARDS) {
const q = normalize(query).trim();
if (!q) return cards.slice();
const hits = [];
const subs = [];
for (const c of cards) {
const t = normalize(c.text);
if (t.includes(q)) hits.push(c);
else if (isSubsequence(q, t)) subs.push(c);
}
return hits.concat(subs);
}
/* ── localStorage helpers ──
Each key is JSON. Reads swallow errors (private mode, quota, SSR) and return
the fallback; writes are best-effort. */
const KEYS = {
favorites: 'oblique_favorites',
kept: 'oblique_kept',
last: 'oblique_last',
queue: 'oblique_queue',
dailySeen: 'oblique_daily_seen',
};
function readJSON(key, fallback) {
if (typeof window === 'undefined') return fallback;
try {
const raw = window.localStorage.getItem(key);
if (raw == null) return fallback;
return JSON.parse(raw);
} catch {
return fallback;
}
}
function writeJSON(key, value) {
if (typeof window === 'undefined') return;
try {
window.localStorage.setItem(key, JSON.stringify(value));
} catch {
/* quota / private mode — state still lives in React, just not persisted */
}
}
export const storage = {
getFavorites: () => {
const v = readJSON(KEYS.favorites, []);
return Array.isArray(v) ? v : [];
},
setFavorites: (ids) => writeJSON(KEYS.favorites, ids),
getKept: () => {
const v = readJSON(KEYS.kept, []);
return Array.isArray(v) ? v : [];
},
setKept: (ids) => writeJSON(KEYS.kept, ids),
getLast: () => readJSON(KEYS.last, null),
setLast: (id) => writeJSON(KEYS.last, id),
getQueue: () => {
const v = readJSON(KEYS.queue, []);
return Array.isArray(v) ? v : [];
},
setQueue: (ids) => writeJSON(KEYS.queue, ids),
getDailySeen: () => readJSON(KEYS.dailySeen, ''),
setDailySeen: (s) => writeJSON(KEYS.dailySeen, s),
};
/* ── deal queue ──
Fisher-Yates over all ids. No-repeat until exhausted: we pop from the back
of a shuffled list; when empty, reshuffle. The queue is persisted so a
refresh mid-deck doesn't reset the no-repeat guarantee. */
export function shuffleIds(ids = CARDS.map((c) => c.id)) {
const a = ids.slice();
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
const tmp = a[i];
a[i] = a[j];
a[j] = tmp;
}
return a;
}
// Ensure a non-empty queue exists; rebuild when empty or malformed.
export function ensureQueue() {
let q = storage.getQueue();
const valid = q.filter((id) => cardById(id) != null);
if (valid.length === 0) {
q = shuffleIds();
storage.setQueue(q);
return q;
}
if (valid.length !== q.length) {
storage.setQueue(valid);
}
return valid;
}
// Draw the next id from the queue (popping from the back), reshuffling if
// needed. Optionally exclude an id (e.g. avoid repeating the current card as
// the very next draw) — excluded id is skipped and pushed to a fresh shuffle.
export function drawFromQueue(excludeId = null) {
let q = ensureQueue();
// pop from back
let id = q.pop();
// skip the excluded card if it's the one we'd draw; push it aside.
if (id === excludeId && q.length > 0) {
const alt = q.pop();
q.push(id);
id = alt;
}
storage.setQueue(q);
storage.setLast(id);
if (q.length === 0) {
// rebuild for next time so the very next draw isn't blocked
const fresh = shuffleIds();
storage.setQueue(fresh);
}
return id;
}
/* ── URL hash ──
Parse /oblique#42, /oblique#browse, /oblique#draw. Returns
{ mode: 'draw'|'browse', cardId: number|null }. */
export function parseHash(hash) {
const h = String(hash || '').replace(/^#/, '').trim();
if (!h) return { mode: 'draw', cardId: null };
if (h === 'browse') return { mode: 'browse', cardId: null };
if (h === 'draw') return { mode: 'draw', cardId: null };
if (/^\d+$/.test(h)) {
const id = Number(h);
if (cardById(id)) return { mode: 'draw', cardId: id };
}
return { mode: 'draw', cardId: null };
}
export function shareUrl(id) {
if (typeof window === 'undefined') return `/oblique#${id}`;
return window.location.origin + '/oblique#' + id;
}
......@@ -23,6 +23,7 @@
"@tailwindcss/postcss": "^4.2.1",
"classnames": "^2.5.1",
"date-fns": "^3.6.0",
"framer-motion": "^13.1.1",
"gray-matter": "^4.0.3",
"hydra-synth": "^1.3.29",
"marked": "^15.0.12",
......
/* Oblique — design lab
Five palette directions for a forthcoming /oblique page (Eno & Schmidt deck).
Scoped tokens only; globals live in _app.js. */
.page {
--ob-maxw: 70rem;
--ob-pad: clamp(1rem, 4vw, 2.5rem);
--ob-safe-top: env(safe-area-inset-top, 0px);
--ob-safe-bottom: env(safe-area-inset-bottom, 0px);
/* palette tokens (defaults = aurora-glass) */
--ob-bg: #0d1117;
--ob-card-bg: rgba(255, 255, 255, 0.06);
--ob-card-border: rgba(255, 255, 255, 0.18);
--ob-ink: #e8e6f0;
--ob-muted: rgba(232, 230, 240, 0.55);
--ob-accent: #7ee8d0;
--ob-font-serif: var(--font-ob-spectral), Georgia, serif;
--ob-font-mono: var(--font-ob-spline-mono), ui-monospace, monospace;
margin: 0;
min-height: 100vh;
padding-top: var(--ob-safe-top);
padding-bottom: var(--ob-safe-bottom);
background-color: var(--ob-bg);
color: var(--ob-ink);
font-family: var(--ob-font-mono);
line-height: 1.55;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
scroll-behavior: smooth;
}
@media (prefers-reduced-motion: reduce) {
.page { scroll-behavior: auto; }
}
/* ── banner ── */
.banner {
position: sticky;
top: 0;
z-index: 30;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.75rem var(--ob-pad);
background: rgba(13, 17, 23, 0.78);
backdrop-filter: blur(14px) saturate(140%);
-webkit-backdrop-filter: blur(14px) saturate(140%);
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.bannerBrand {
font-family: var(--ob-font-mono);
font-size: 0.8rem;
letter-spacing: 0.22em;
text-transform: uppercase;
color: var(--ob-ink);
}
.bannerBrand b { color: var(--ob-accent); font-weight: 500; }
.bannerBrand i { font-style: normal; color: var(--ob-muted); }
.bannerBack {
font-family: var(--ob-font-mono);
font-size: 0.78rem;
letter-spacing: 0.12em;
color: var(--ob-muted);
text-decoration: none;
padding: 0.5rem 0.9rem;
min-height: 44px;
display: inline-flex;
align-items: center;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 999px;
transition: color 0.2s, border-color 0.2s;
}
.bannerBack:hover { color: var(--ob-ink); border-color: rgba(255, 255, 255, 0.3); }
/* ── shell ── */
.main { max-width: var(--ob-maxw); margin: 0 auto; padding: 0 var(--ob-pad); }
.section { padding: clamp(3rem, 8vw, 5rem) 0; border-bottom: 1px solid rgba(255, 255, 255, 0.06); }
.sectionHead { margin-bottom: 1.75rem; }
.sectionNum {
font-family: var(--ob-font-mono);
font-size: 0.72rem;
letter-spacing: 0.28em;
text-transform: uppercase;
color: var(--ob-muted);
display: block;
margin-bottom: 0.5rem;
}
.sectionTitle {
font-family: var(--font-ob-fraunces), var(--ob-font-serif);
font-variation-settings: 'opsz' 120, 'SOFT' 40;
font-size: clamp(1.6rem, 4vw, 2.4rem);
font-weight: 500;
line-height: 1.1;
margin: 0 0 0.5rem;
letter-spacing: -0.01em;
}
.sectionLede {
font-family: var(--ob-font-mono);
font-size: 0.85rem;
color: var(--ob-muted);
max-width: 48ch;
margin: 0;
}
/* ── §1 palette tiles ── */
.tilesGrid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 11rem), 1fr));
gap: 1rem;
align-items: start;
}
.tile {
position: relative;
cursor: pointer;
border: none;
background: transparent;
padding: 0;
margin: 0;
text-align: left;
font: inherit;
color: inherit;
min-height: 44px;
border-radius: 14px;
}
.tileExpanded { grid-column: 1 / -1; }
.tileCard {
position: relative;
border-radius: 14px;
padding: 1.1rem 1rem 0.9rem;
min-height: 9rem;
overflow: hidden;
display: flex;
flex-direction: column;
justify-content: space-between;
gap: 0.75rem;
}
.tileLabel {
font-family: var(--ob-font-mono);
font-size: 0.66rem;
letter-spacing: 0.2em;
text-transform: uppercase;
}
.tileText {
font-family: var(--ob-font-serif);
font-size: 1.05rem;
line-height: 1.3;
}
.tileExpanded .tileText { font-size: clamp(1.4rem, 3.5vw, 1.9rem); }
.tileFooter {
font-family: var(--ob-font-mono);
font-size: 0.7rem;
letter-spacing: 0.08em;
opacity: 0.7;
}
.tileHex {
position: absolute;
top: 0.4rem;
right: 0.7rem;
font-size: 2.2rem;
opacity: 0.14;
line-height: 1;
pointer-events: none;
}
/* — palette: void + paper — */
.palVoid { background: #0a0a1a; }
.palVoid .tileCard { background: #f4ecd8; color: #1a1a2a; border: 1px solid rgba(0,0,0,0.08); box-shadow: 0 18px 40px -18px rgba(0,0,0,0.7); }
.palVoid .tileLabel { color: #6b5a3a; }
.palVoid .tileHex { color: #1a1a2a; }
/* — palette: amber CRT — */
.palAmber { background: #1a1408; }
.palAmber .tileCard {
background: #1a1408; color: #ffb000;
border: 1px solid rgba(255, 176, 0, 0.25);
box-shadow: inset 0 0 30px rgba(255, 176, 0, 0.05);
}
.palAmber .tileLabel { color: #33ff66; }
.palAmber .tileText { font-family: var(--ob-font-mono); color: #ffb000; }
.palAmber .tileFooter { color: #33ff66; }
.palAmber .tileHex { color: #33ff66; }
.scanlines::after {
content: ''; position: absolute; inset: 0; pointer-events: none; border-radius: inherit;
background: repeating-linear-gradient(0deg, rgba(0,0,0,0.28) 0 1px, transparent 1px 3px);
opacity: 0.6;
}
.grain::before {
content: ''; position: absolute; inset: 0; pointer-events: none; border-radius: inherit; opacity: 0.08;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
mix-blend-mode: overlay;
}
/* — palette: iridescent oil-on-paper (LIGHT) — */
.palIridescent { background: #f4ecd8; }
.palIridescent .tileCard {
background: #fbf6ea; color: #2a2438;
position: relative;
}
.foilWrap { position: relative; border-radius: 14px; padding: 2px; }
.foilLayer {
position: absolute; inset: 0; border-radius: 14px;
background: conic-gradient(from 0deg, #ff6ec7, #ffd166, #06d6a0, #4cc9f0, #b388ff, #ff6ec7);
filter: saturate(1.3) blur(0.3px);
opacity: 0.75;
}
.palIridescent .tileLabel { color: #8a6a3a; }
.palIridescent .tileHex { color: #2a2438; opacity: 0.1; }
/* — palette: aurora-glass — */
.palAurora { background: #0d1117; }
.auroraBg {
position: absolute; inset: 0; border-radius: 14px; overflow: hidden; pointer-events: none;
}
.auroraBlob {
position: absolute; width: 120%; height: 120%; top: -10%; left: -10%;
background:
radial-gradient(40% 50% at 30% 30%, rgba(126, 232, 208, 0.35), transparent 60%),
radial-gradient(45% 55% at 70% 65%, rgba(154, 100, 255, 0.4), transparent 65%),
radial-gradient(35% 40% at 50% 50%, rgba(79, 195, 247, 0.22), transparent 60%);
filter: blur(20px);
}
.palAurora .tileCard, .glass {
background: rgba(255, 255, 255, 0.06);
backdrop-filter: blur(12px) saturate(140%);
-webkit-backdrop-filter: blur(12px) saturate(140%);
border: 1px solid rgba(255, 255, 255, 0.18);
color: #e8e6f0;
}
.palAurora .tileLabel { color: #7ee8d0; }
.palAurora .tileFooter { color: rgba(232, 230, 240, 0.6); }
.palAurora .tileHex { color: #7ee8d0; }
/* — palette: fused (aurora-glass + iridescent foil) — */
.palFused { background: #0d1117; }
.palFused .auroraBg { opacity: 0.7; }
.palFused .foilWrap { border-radius: 14px; padding: 2px; position: relative; }
.palFused .glass { background: rgba(13, 17, 23, 0.5); }
.palFused .tileLabel { color: #b388ff; }
.palFused .tileFooter { color: rgba(232, 230, 240, 0.6); }
.palFused .tileHex { color: #b388ff; }
/* ── §2 typography ── */
.pairToggle {
display: flex; flex-wrap: wrap; gap: 0.5rem; margin-bottom: 1.5rem;
}
.pairRadio {
appearance: none; -webkit-appearance: none; position: absolute; opacity: 0;
}
.pairRadioLabel {
font-family: var(--ob-font-mono); font-size: 0.72rem; letter-spacing: 0.08em;
padding: 0.5rem 0.9rem; min-height: 44px; display: inline-flex; align-items: center;
border: 1px solid rgba(255,255,255,0.14); border-radius: 999px; cursor: pointer;
color: var(--ob-muted); transition: color 0.2s, border-color 0.2s, background 0.2s;
}
.pairRadio:checked + .pairRadioLabel {
color: #0d1117; background: var(--ob-accent); border-color: var(--ob-accent);
}
.pairGrid {
display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 18rem), 1fr));
gap: 1rem;
}
.pairSpecimen {
border: 1px solid rgba(255,255,255,0.1); border-radius: 14px; padding: 1.5rem;
background: rgba(255,255,255,0.03);
}
.pairName {
font-family: var(--ob-font-mono); font-size: 0.7rem; letter-spacing: 0.14em;
text-transform: uppercase; color: var(--ob-muted); margin: 0 0 1rem;
}
.pairSerif {
font-size: clamp(1.25rem, 3vw, 1.75rem); line-height: 1.25; margin: 0 0 0.9rem;
}
.pairMono {
font-family: var(--ob-font-mono); font-size: 0.82rem; letter-spacing: 0.06em;
color: var(--ob-muted);
}
/* ── §3 anatomy ── */
.anatomyGrid {
display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 13rem), 1fr));
gap: 1rem;
}
.anatomyCard {
position: relative; border-radius: 14px; padding: 1.4rem 1.1rem 1rem;
min-height: 11rem; overflow: hidden;
display: flex; flex-direction: column; justify-content: space-between; gap: 0.8rem;
}
.anatomyName {
font-family: var(--ob-font-mono); font-size: 0.66rem; letter-spacing: 0.18em;
text-transform: uppercase; color: var(--ob-muted); margin-bottom: 0.5rem;
}
.anatomyHexTop {
font-size: 1.6rem; opacity: 0.5; line-height: 1; text-align: center;
}
.anatomyText {
font-family: var(--ob-font-serif); font-size: 1.05rem; line-height: 1.32;
}
.anatomyMeta {
font-family: var(--ob-font-mono); font-size: 0.68rem; letter-spacing: 0.08em;
color: rgba(232,230,240,0.6); display: flex; justify-content: space-between; gap: 0.5rem;
flex-wrap: wrap;
}
.anatomyFav { color: #7ee8d0; }
.geoBg {
position: absolute; inset: 0; pointer-events: none; opacity: 0.09;
display: flex; align-items: center; justify-content: center;
}
.geoDivider { display: flex; justify-content: center; opacity: 0.4; margin: 1rem 0; }
/* ── §4 reveal playground ── */
.revealStage {
position: relative; min-height: 22rem; display: flex; align-items: center; justify-content: center;
border: 1px dashed rgba(255,255,255,0.14); border-radius: 18px; padding: 1.5rem;
margin-bottom: 1.25rem; perspective: 1200px;
}
.revealScene { position: relative; width: min(20rem, 80vw); height: 28rem; }
.flipCard {
position: absolute; inset: 0; transform-style: preserve-3d;
border-radius: 16px;
}
.flipFace {
position: absolute; inset: 0; border-radius: 16px; backface-visibility: hidden;
-webkit-backface-visibility: hidden;
display: flex; flex-direction: column; justify-content: space-between; gap: 0.8rem;
padding: 1.6rem 1.3rem;
overflow: hidden;
}
.flipBack { transform: rotateY(180deg);
background: linear-gradient(135deg, #1a1330, #2a1f4a);
border: 1px solid rgba(126, 232, 208, 0.25);
align-items: center; justify-content: center;
}
.flipBackHex { font-size: 3.5rem; opacity: 0.4; }
.flipFront {
background: rgba(255,255,255,0.06);
backdrop-filter: blur(12px) saturate(140%);
-webkit-backdrop-filter: blur(12px) saturate(140%);
border: 1px solid rgba(255,255,255,0.18);
}
.tossCard {
position: absolute; inset: 0; border-radius: 16px;
background: rgba(255,255,255,0.06);
backdrop-filter: blur(12px) saturate(140%);
-webkit-backdrop-filter: blur(12px) saturate(140%);
border: 1px solid rgba(255,255,255,0.18);
display: flex; flex-direction: column; justify-content: space-between; gap: 0.8rem;
padding: 1.6rem 1.3rem;
overflow: hidden;
}
.drawText { font-family: var(--ob-font-serif); font-size: clamp(1.2rem, 3.5vw, 1.6rem); line-height: 1.25; }
.drawFooter { font-family: var(--ob-font-mono); font-size: 0.72rem; letter-spacing: 0.08em; color: rgba(232,230,240,0.6); }
.drawHex { position: absolute; top: 0.6rem; right: 0.9rem; font-size: 2.4rem; opacity: 0.16; }
.revealHint { font-family: var(--ob-font-mono); font-size: 0.75rem; color: var(--ob-muted); text-align: center; margin: 0; }
.revealButtons { display: flex; flex-wrap: wrap; gap: 0.75rem; justify-content: center; align-items: center; }
.revealBtn {
font-family: var(--ob-font-mono); font-size: 0.8rem; letter-spacing: 0.1em; text-transform: uppercase;
padding: 0.85rem 1.4rem; min-height: 48px; cursor: pointer;
border: 1px solid rgba(255,255,255,0.2); border-radius: 999px;
background: rgba(255,255,255,0.04); color: var(--ob-ink);
transition: background 0.2s, border-color 0.2s, transform 0.15s;
}
.revealBtn:hover { background: rgba(126, 232, 208, 0.12); border-color: rgba(126,232,208,0.4); }
.revealBtn:active { transform: scale(0.97); }
.revealCounter {
font-family: var(--ob-font-mono); font-size: 0.72rem; color: var(--ob-muted);
letter-spacing: 0.1em;
}
/* ── footer ── */
.footer {
padding: 2.5rem var(--ob-pad) 3rem; text-align: center;
font-family: var(--ob-font-mono); font-size: 0.72rem; color: var(--ob-muted);
letter-spacing: 0.14em; text-transform: uppercase;
}
@media (max-width: 640px) {
.tileText { font-size: 0.95rem; }
.revealStage { min-height: 18rem; }
.revealScene { height: 24rem; }
}
/* oblique — aurora-glass + max filigree.
Dark #0d1117 base, drifting aurora blobs, frosted-glass cards with an
iridescent conic foil edge, Flower-of-Life sacred geometry as a co-star,
I Ching hexagram filigree. Fonts: Fraunces (serif) for strategy text +
wordmark, Space Mono for ordinals/captions/controls.
CSS variable names (--ob-serif, --ob-mono, --ob-*) are intentionally
distinct from oblique.design.module.css so the two pages don't bleed. */
.page {
/* tokens */
--ob-bg: #0d1117;
--ob-bg-deep: #080b10;
--ob-surface: rgba(255, 255, 255, 0.06);
--ob-surface-2: rgba(255, 255, 255, 0.10);
--ob-border: rgba(255, 255, 255, 0.14);
--ob-border-strong: rgba(255, 255, 255, 0.22);
--ob-ink: #f2f4f8;
--ob-muted: #9aa4b2;
--ob-teal: #0fb5b5;
--ob-purple: #7c3aed;
--ob-indigo: #4f46e5;
--ob-amber: #f59e0b;
/* next/font populates --font-ob-serif / --font-ob-mono (see pages/oblique/index.js);
these are the family stacks consumed across the module. */
--ob-serif: var(--font-ob-serif, 'Fraunces'), Georgia, 'Times New Roman', serif;
--ob-mono: var(--font-ob-mono, 'Space Mono'), ui-monospace, 'SFMono-Regular', monospace;
--ob-maxw: 72rem;
margin: 0;
min-height: 100vh;
min-height: 100svh;
display: flex;
flex-direction: column;
background-color: var(--ob-bg);
color: var(--ob-ink);
font-family: var(--ob-serif);
line-height: 1.6;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
position: relative;
overflow-x: hidden;
}
.main {
flex: 1;
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
}
/* ════════════════════════════ AURORA ══ */
.aurora {
position: fixed;
inset: 0;
z-index: 0;
overflow: hidden;
pointer-events: none;
background: var(--ob-bg-deep);
}
.auroraBlob {
position: absolute;
top: 50%;
left: 50%;
border-radius: 50%;
filter: blur(60px);
transform: translate(-50%, -50%);
will-change: transform;
mix-blend-mode: screen;
}
.auroraBlob:nth-child(1) { transform: translate(-58%, -55%); }
.auroraBlob:nth-child(2) { transform: translate(-42%, -48%); }
.auroraBlob:nth-child(3) { transform: translate(-50%, -52%); }
.grain {
position: absolute;
inset: 0;
background-size: 160px 160px;
opacity: 0.04;
mix-blend-mode: overlay;
}
/* ════════════════════════════ TOP BAR ══ */
.topBar {
display: flex;
flex-direction: column;
gap: 1rem;
padding: calc(env(safe-area-inset-top, 0px) + 1.4rem) clamp(1rem, 4vw, 2rem) 0.6rem;
}
.wordmarkWrap { display: flex; flex-direction: column; gap: 0.2rem; }
.wordmark {
font-family: var(--ob-serif);
font-weight: 500;
font-variation-settings: 'opsz' 144, 'SOFT' 50, 'WONK' 1;
font-size: clamp(2.4rem, 9vw, 4.4rem);
letter-spacing: -0.03em;
line-height: 0.95;
margin: 0;
color: var(--ob-ink);
}
.tagline {
font-family: var(--ob-mono);
font-size: 0.78rem;
letter-spacing: 0.04em;
color: var(--ob-muted);
margin: 0;
}
.modeToggle {
display: inline-flex;
gap: 0.4rem;
align-self: flex-start;
}
.tabBtn {
font-family: var(--ob-mono);
font-size: 0.8rem;
letter-spacing: 0.08em;
padding: 0.5rem 1.1rem;
border-radius: 999px;
border: 1px solid var(--ob-border);
background: var(--ob-surface);
color: var(--ob-muted);
cursor: pointer;
min-height: 44px;
transition: color 0.2s ease, border-color 0.2s ease, background 0.2s ease;
}
.tabBtn:hover { color: var(--ob-ink); border-color: var(--ob-border-strong); }
.tabOn {
color: var(--ob-bg);
background: var(--ob-ink);
border-color: var(--ob-ink);
}
/* ════════════════════════════ STAGE ══ */
.stage {
flex: 1;
display: flex;
flex-direction: column;
padding: 1.4rem clamp(1rem, 4vw, 2rem) 2rem;
}
.boot { min-height: 50vh; }
/* ════════════════════════════ CARD ══ */
.card {
position: relative;
border-radius: 18px;
background: var(--ob-surface);
backdrop-filter: blur(14px) saturate(1.1);
-webkit-backdrop-filter: blur(14px) saturate(1.1);
border: 1px solid var(--ob-border);
box-shadow:
0 18px 50px rgba(0, 0, 0, 0.45),
0 2px 0 rgba(255, 255, 255, 0.05) inset;
overflow: hidden;
isolation: isolate;
user-select: none;
}
.cardDraw {
width: min(92vw, 30rem);
aspect-ratio: 5 / 7;
margin: 0 auto;
}
.cardBrowse {
width: 100%;
aspect-ratio: 5 / 6;
}
/* iridescent foil edge — a conic-gradient ring shifted on hover via --foil-angle.
Applied as a pseudo so the glass face stays clean inside. */
.foilEdge {
position: absolute;
inset: 0;
border-radius: inherit;
pointer-events: none;
padding: 1px;
background: conic-gradient(
from var(--foil-angle, 0deg),
#ff8a5c, #7c3aed, #0fb5b5, #f59e0b, #ff8a5c
);
-webkit-mask:
linear-gradient(#000 0 0) content-box,
linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask:
linear-gradient(#000 0 0) content-box,
linear-gradient(#000 0 0);
mask-composite: exclude;
opacity: 0.55;
transition: opacity 0.3s ease;
z-index: 3;
}
.card:hover .foilEdge {
opacity: 1;
--foil-angle: 90deg;
}
.card3d { perspective: 1400px; }
.cardInner {
position: relative;
width: 100%;
height: 100%;
transform-style: preserve-3d;
}
.cardHalf {
position: absolute;
inset: 0;
border-radius: inherit;
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
}
.cardHalfFront { transform: rotateY(0deg); transition: transform 0.6s 0.35s cubic-bezier(0.16, 1, 0.3, 1); }
/* settled resting tilt once the card lands face-up */
.cardHalfFront.tilted { transform: rotateY(0deg) rotateZ(-1.5deg); }
/* card back */
.cardBack {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 0.8rem;
}
.cardBackFlower {
position: absolute;
inset: 0;
width: 80%;
height: 80%;
margin: auto;
color: var(--ob-ink);
}
.cardBackHex {
font-family: var(--ob-mono);
font-size: clamp(3rem, 12vw, 5rem);
color: var(--ob-ink);
opacity: 0.22;
line-height: 1;
position: relative;
z-index: 2;
}
.cardBackMark {
font-family: var(--ob-mono);
font-size: 0.72rem;
letter-spacing: 0.2em;
text-transform: uppercase;
color: var(--ob-muted);
position: relative;
z-index: 2;
}
/* card face */
.cardFace {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
padding: 1.4rem 1.3rem;
border-radius: inherit;
}
.cardFaceFlower {
position: absolute;
inset: -10%;
width: 120%;
height: 120%;
margin: auto;
color: var(--ob-teal);
z-index: 0;
opacity: 0.22; /* MAX FILIGREE but legible: text patch carries the halo */
}
.cardHex {
position: absolute;
top: 0.9rem;
left: 1.1rem;
font-family: var(--ob-mono);
font-size: clamp(1.6rem, 6vw, 2.4rem);
color: var(--ob-ink);
opacity: 0.16;
line-height: 1;
z-index: 1;
}
.cardOrdinal {
position: absolute;
top: 1.1rem;
right: 1.2rem;
font-family: var(--ob-mono);
font-size: 0.72rem;
letter-spacing: 0.04em;
color: var(--ob-muted);
z-index: 2;
}
.cardTextPatch {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
z-index: 2;
/* denser glass patch behind the text for legibility over the Flower-of-Life */
background: rgba(13, 17, 23, 0.34);
border-radius: 10px;
margin: 2.6rem 0 1.6rem;
padding: 0.9rem 0.7rem;
backdrop-filter: blur(2px);
-webkit-backdrop-filter: blur(2px);
}
.cardText {
font-family: var(--ob-serif);
font-weight: 450;
font-variation-settings: 'opsz' 80, 'SOFT' 30;
font-size: clamp(1.4rem, 5vw, 2.4rem);
line-height: 1.18;
letter-spacing: -0.01em;
color: var(--ob-ink);
text-align: center;
margin: 0;
text-wrap: balance;
text-shadow: 0 1px 8px rgba(0, 0, 0, 0.5);
}
/* favorite ◆ */
.favBtn, .favMark {
position: absolute;
bottom: 0.9rem;
right: 1.1rem;
z-index: 3;
font-family: var(--ob-mono);
font-size: 1.1rem;
line-height: 1;
color: var(--ob-muted);
background: none;
border: none;
cursor: pointer;
padding: 0.4rem;
min-width: 44px;
min-height: 44px;
display: inline-flex;
align-items: center;
justify-content: center;
transition: color 0.2s ease, transform 0.2s ease;
}
.favBtn:hover { transform: scale(1.1); }
.favOn { color: var(--ob-amber); }
/* ════════════════════════════ DRAW ══ */
.draw {
flex: 1;
display: flex;
flex-direction: column;
gap: 1.6rem;
}
.drawStage {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1.1rem;
position: relative;
}
.dailyCaption {
font-family: var(--ob-mono);
font-size: 0.74rem;
letter-spacing: 0.08em;
color: var(--ob-amber);
margin: 0;
}
.drawCardWrap {
position: relative;
touch-action: pan-y;
cursor: grab;
}
.drawCardWrap:active { cursor: grabbing; }
/* directional swipe hints — faint, brighten on drag (via sibling hover/drag) */
.swipeHint {
position: absolute;
z-index: 4;
font-family: var(--ob-mono);
font-size: 0.68rem;
letter-spacing: 0.06em;
color: var(--ob-muted);
opacity: 0.5;
pointer-events: none;
transition: opacity 0.2s ease;
}
.swipeLeft { left: -0.4rem; top: 50%; transform: translateY(-50%) translateX(-100%); }
.swipeRight { right: -0.4rem; top: 50%; transform: translateY(-50%) translateX(100%); }
.swipeUp { top: -0.4rem; left: 50%; transform: translateX(-50%) translateY(-100%); }
.drawActions {
display: flex;
gap: 0.8rem;
align-items: center;
justify-content: center;
}
.ghostBtn {
font-family: var(--ob-mono);
font-size: 0.8rem;
letter-spacing: 0.04em;
color: var(--ob-muted);
background: none;
border: 1px solid var(--ob-border);
border-radius: 999px;
padding: 0.5rem 1rem;
cursor: pointer;
min-height: 46px;
transition: color 0.2s ease, border-color 0.2s ease, background 0.2s ease;
}
.ghostBtn:hover { color: var(--ob-ink); border-color: var(--ob-border-strong); background: var(--ob-surface); }
.drawHint {
font-family: var(--ob-mono);
font-size: 0.72rem;
color: var(--ob-muted);
opacity: 0.7;
margin: 0;
text-align: center;
}
.toast {
font-family: var(--ob-mono);
font-size: 0.74rem;
color: var(--ob-teal);
background: var(--ob-surface-2);
border: 1px solid var(--ob-border);
padding: 0.4rem 0.8rem;
border-radius: 999px;
}
/* kept-spread */
.kept {
flex-shrink: 0;
padding-bottom: calc(env(safe-area-inset-bottom, 0px) + 0.6rem);
}
.keptHeader {
font-family: var(--ob-mono);
font-size: 0.74rem;
letter-spacing: 0.06em;
color: var(--ob-muted);
margin: 0 0 0.6rem;
}
.keptEmpty {
font-family: var(--ob-mono);
font-size: 0.72rem;
color: var(--ob-muted);
opacity: 0.5;
margin: 0;
}
.keptRow {
display: flex;
gap: 0.6rem;
overflow-x: auto;
padding-bottom: 0.4rem;
scrollbar-width: thin;
scrollbar-color: var(--ob-border-strong) transparent;
}
.keptChip {
flex: 0 0 auto;
display: flex;
align-items: stretch;
gap: 0;
border: 1px solid var(--ob-border);
border-radius: 10px;
background: var(--ob-surface);
overflow: hidden;
}
.keptChipBody {
display: flex;
flex-direction: column;
gap: 0.2rem;
background: none;
border: none;
color: var(--ob-ink);
cursor: pointer;
padding: 0.6rem 0.7rem;
max-width: 13rem;
text-align: left;
min-height: 44px;
}
.keptChipHex {
font-family: var(--ob-mono);
font-size: 1rem;
color: var(--ob-teal);
line-height: 1;
}
.keptChipText {
font-family: var(--ob-serif);
font-size: 0.8rem;
color: var(--ob-muted);
line-height: 1.25;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.keptRemove {
background: none;
border: none;
border-left: 1px solid var(--ob-border);
color: var(--ob-muted);
cursor: pointer;
padding: 0 0.5rem;
min-width: 28px;
font-size: 0.8rem;
transition: color 0.2s ease, background 0.2s ease;
}
.keptRemove:hover { color: var(--ob-amber); background: rgba(245, 158, 11, 0.1); }
/* ════════════════════════════ BROWSE ══ */
.browse {
display: flex;
flex-direction: column;
gap: 1.4rem;
max-width: var(--ob-maxw);
width: 100%;
margin: 0 auto;
}
.browseBar {
display: flex;
flex-wrap: wrap;
gap: 0.7rem;
align-items: center;
}
.searchInput {
flex: 1 1 18rem;
font-family: var(--ob-mono);
font-size: 0.9rem;
color: var(--ob-ink);
background: var(--ob-surface);
border: 1px solid var(--ob-border);
border-radius: 999px;
padding: 0.6rem 1.1rem;
min-height: 44px;
outline: none;
transition: border-color 0.2s ease;
}
.searchInput::placeholder { color: var(--ob-muted); opacity: 0.7; }
.searchInput:focus { border-color: var(--ob-teal); }
.matchCount {
font-family: var(--ob-mono);
font-size: 0.74rem;
color: var(--ob-muted);
letter-spacing: 0.04em;
}
.pill {
font-family: var(--ob-mono);
font-size: 0.78rem;
letter-spacing: 0.04em;
color: var(--ob-muted);
background: var(--ob-surface);
border: 1px solid var(--ob-border);
border-radius: 999px;
padding: 0.5rem 1rem;
cursor: pointer;
min-height: 44px;
transition: color 0.2s ease, border-color 0.2s ease, background 0.2s ease;
}
.pill:hover { color: var(--ob-ink); border-color: var(--ob-border-strong); }
.pillOn { color: var(--ob-amber); border-color: rgba(245, 158, 11, 0.5); background: rgba(245, 158, 11, 0.1); }
.browseEmpty {
font-family: var(--ob-mono);
color: var(--ob-muted);
text-align: center;
padding: 3rem 0;
}
/* responsive grid — generous breathing room on desktop (the owner asked for
empty space, not cram). Mobile 1 col, tablet 2-3, desktop 3-4, wide gaps. */
.grid {
display: grid;
grid-template-columns: repeat(1, 1fr);
gap: 1.4rem;
}
@media (min-width: 640px) {
.grid { grid-template-columns: repeat(2, 1fr); gap: 1.6rem; }
}
@media (min-width: 960px) {
.grid { grid-template-columns: repeat(3, 1fr); gap: 2rem; }
}
@media (min-width: 1280px) {
.grid { grid-template-columns: repeat(4, 1fr); gap: 2.2rem; }
}
.gridCard {
background: none;
border: none;
padding: 0;
cursor: pointer;
text-align: left;
display: block;
transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1);
}
.gridCard:hover { transform: translateY(-4px); }
.gridCard:focus-visible { outline: 2px solid var(--ob-teal); outline-offset: 4px; border-radius: 20px; }
/* compact browse card: smaller text, 2-line clamp */
.gridCard .cardBrowse .cardFace { padding: 1rem 1rem; }
.gridCard .cardText {
font-size: clamp(0.95rem, 1.6vw, 1.1rem);
line-height: 1.3;
text-align: left;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-shadow: none;
}
.gridCard .cardTextPatch { margin: 2rem 0 0.5rem; padding: 0; background: none; backdrop-filter: none; -webkit-backdrop-filter: none; }
.gridCard .cardHex { font-size: 1.4rem; }
.gridCard .favMark { font-size: 0.9rem; }
/* ════════════════════════════ OVERLAY ══ */
.overlay {
position: fixed;
inset: 0;
z-index: 100;
background: rgba(8, 11, 16, 0.82);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
display: flex;
align-items: center;
justify-content: center;
padding: 1.2rem;
}
.overlayCard {
display: flex;
flex-direction: column;
gap: 1rem;
align-items: center;
max-width: 92vw;
}
.overlayActions {
display: flex;
gap: 0.7rem;
}
/* ════════════════════════════ FOOTER ══ */
.footer {
flex-shrink: 0;
padding: 1.4rem;
border-top: 1px solid var(--ob-border);
text-align: center;
}
.footerMono {
font-family: var(--ob-mono);
font-size: 0.76rem;
color: var(--ob-muted);
}
.footerMono a { color: var(--ob-teal); text-decoration: none; }
.footerMono a:hover { color: var(--ob-ink); }
/* ════════════════════════════ FLOWER helper ══ */
.flower {
display: block;
width: 100%;
height: 100%;
pointer-events: none;
}
/* ════════════════════════════ REDUCED MOTION ══ */
@media (prefers-reduced-motion: reduce) {
.page *, .page *::before, .page *::after {
animation-duration: 0.001ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.001ms !important;
}
/* aurora static, flip → fade (handled in JS). The resting tilt is a static
transform, not motion, so it may stay; only its transition is killed above. */
}
/* freeze aurora on metered/low-data connections */
@media (prefers-reduced-data: reduce) {
.auroraBlob { filter: none; opacity: 0.4; }
.grain { display: none; }
}
/* ════════════════════════════ FOCUS ══ */
.page a:focus-visible,
.page button:focus-visible,
.page input:focus-visible {
outline: 2px solid var(--ob-teal);
outline-offset: 3px;
border-radius: 4px;
}
......@@ -3957,6 +3957,25 @@ __metadata:
languageName: node
linkType: hard
"framer-motion@npm:^13.1.1":
version: 13.1.1
resolution: "framer-motion@npm:13.1.1"
dependencies:
motion-dom: "npm:^13.1.1"
motion-utils: "npm:^13.0.0"
tslib: "npm:^2.4.0"
peerDependencies:
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
peerDependenciesMeta:
react:
optional: true
react-dom:
optional: true
checksum: 10c0/db1601442047bbd06286ef9fd5c9af06b4530984dc0cd0f77dffa8590a8dbcac92db05e992b62041455b55db6791bdfba8cd7c0aaa920a2325eba2c277fb7baa
languageName: node
linkType: hard
"fs-extra@npm:11.1.0":
version: 11.1.0
resolution: "fs-extra@npm:11.1.0"
......@@ -6662,6 +6681,22 @@ __metadata:
languageName: node
linkType: hard
"motion-dom@npm:^13.1.1":
version: 13.1.1
resolution: "motion-dom@npm:13.1.1"
dependencies:
motion-utils: "npm:^13.0.0"
checksum: 10c0/c7697e6937920f9f584c04d59fe8589e7bbca18a85d2cf1ec7cf4ee229fb29054dda41bec6db2dde4fdf43436f17cfc2a3bbab06d2d9f8232dfa7921e0382d20
languageName: node
linkType: hard
"motion-utils@npm:^13.0.0":
version: 13.0.0
resolution: "motion-utils@npm:13.0.0"
checksum: 10c0/4b279ce85f71926d668ff5966feb898b174541d16952a61c7150982388553266602ecd2a76e057530076ff8f38d06672e585639ddc73c41dd1a67d04aaa3662d
languageName: node
linkType: hard
"mri@npm:1.2.0, mri@npm:^1.1.0":
version: 1.2.0
resolution: "mri@npm:1.2.0"
......@@ -7284,6 +7319,7 @@ __metadata:
"@types/react": "npm:^18.2.61"
classnames: "npm:^2.5.1"
date-fns: "npm:^3.6.0"
framer-motion: "npm:^13.1.1"
gray-matter: "npm:^4.0.3"
hydra-synth: "npm:^1.3.29"
jest: "npm:^30.1.3"
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment