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 = ...@@ -37,6 +37,7 @@ const RAW_RUNTIME_STATE =
["@types/react", "npm:18.3.27"],\ ["@types/react", "npm:18.3.27"],\
["classnames", "npm:2.5.1"],\ ["classnames", "npm:2.5.1"],\
["date-fns", "npm:3.6.0"],\ ["date-fns", "npm:3.6.0"],\
["framer-motion", "virtual:599f9545309f05166d506895056809d0f5971d8f55d13475681739816b4bb6745eb725b53ed2108e22ea4ce46b8a2c491882d38a9948455117b545da3880eea7#npm:13.1.1"],\
["gray-matter", "npm:4.0.3"],\ ["gray-matter", "npm:4.0.3"],\
["hydra-synth", "npm:1.4.0"],\ ["hydra-synth", "npm:1.4.0"],\
["jest", "virtual:599f9545309f05166d506895056809d0f5971d8f55d13475681739816b4bb6745eb725b53ed2108e22ea4ce46b8a2c491882d38a9948455117b545da3880eea7#npm:30.2.0"],\ ["jest", "virtual:599f9545309f05166d506895056809d0f5971d8f55d13475681739816b4bb6745eb725b53ed2108e22ea4ce46b8a2c491882d38a9948455117b545da3880eea7#npm:30.2.0"],\
...@@ -5181,6 +5182,35 @@ const RAW_RUNTIME_STATE = ...@@ -5181,6 +5182,35 @@ const RAW_RUNTIME_STATE =
"linkType": "HARD"\ "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", [\ ["fs-extra", [\
["npm:11.1.0", {\ ["npm:11.1.0", {\
"packageLocation": "../../../.yarn/berry/cache/fs-extra-npm-11.1.0-2cd157426f-10c0.zip/node_modules/fs-extra/",\ "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 = ...@@ -8148,6 +8178,25 @@ const RAW_RUNTIME_STATE =
"linkType": "HARD"\ "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", [\ ["mri", [\
["npm:1.2.0", {\ ["npm:1.2.0", {\
"packageLocation": "../../../.yarn/berry/cache/mri-npm-1.2.0-8ecee0357d-10c0.zip/node_modules/mri/",\ "packageLocation": "../../../.yarn/berry/cache/mri-npm-1.2.0-8ecee0357d-10c0.zip/node_modules/mri/",\
...@@ -8849,6 +8898,7 @@ const RAW_RUNTIME_STATE = ...@@ -8849,6 +8898,7 @@ const RAW_RUNTIME_STATE =
["@types/react", "npm:18.3.27"],\ ["@types/react", "npm:18.3.27"],\
["classnames", "npm:2.5.1"],\ ["classnames", "npm:2.5.1"],\
["date-fns", "npm:3.6.0"],\ ["date-fns", "npm:3.6.0"],\
["framer-motion", "virtual:599f9545309f05166d506895056809d0f5971d8f55d13475681739816b4bb6745eb725b53ed2108e22ea4ce46b8a2c491882d38a9948455117b545da3880eea7#npm:13.1.1"],\
["gray-matter", "npm:4.0.3"],\ ["gray-matter", "npm:4.0.3"],\
["hydra-synth", "npm:1.4.0"],\ ["hydra-synth", "npm:1.4.0"],\
["jest", "virtual:599f9545309f05166d506895056809d0f5971d8f55d13475681739816b4bb6745eb725b53ed2108e22ea4ce46b8a2c491882d38a9948455117b545da3880eea7#npm:30.2.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>
);
}
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>
);
}
/* 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 @@ ...@@ -23,6 +23,7 @@
"@tailwindcss/postcss": "^4.2.1", "@tailwindcss/postcss": "^4.2.1",
"classnames": "^2.5.1", "classnames": "^2.5.1",
"date-fns": "^3.6.0", "date-fns": "^3.6.0",
"framer-motion": "^13.1.1",
"gray-matter": "^4.0.3", "gray-matter": "^4.0.3",
"hydra-synth": "^1.3.29", "hydra-synth": "^1.3.29",
"marked": "^15.0.12", "marked": "^15.0.12",
......
...@@ -3957,6 +3957,25 @@ __metadata: ...@@ -3957,6 +3957,25 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "fs-extra@npm:11.1.0":
version: 11.1.0 version: 11.1.0
resolution: "fs-extra@npm:11.1.0" resolution: "fs-extra@npm:11.1.0"
...@@ -6662,6 +6681,22 @@ __metadata: ...@@ -6662,6 +6681,22 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "mri@npm:1.2.0, mri@npm:^1.1.0":
version: 1.2.0 version: 1.2.0
resolution: "mri@npm:1.2.0" resolution: "mri@npm:1.2.0"
...@@ -7284,6 +7319,7 @@ __metadata: ...@@ -7284,6 +7319,7 @@ __metadata:
"@types/react": "npm:^18.2.61" "@types/react": "npm:^18.2.61"
classnames: "npm:^2.5.1" classnames: "npm:^2.5.1"
date-fns: "npm:^3.6.0" date-fns: "npm:^3.6.0"
framer-motion: "npm:^13.1.1"
gray-matter: "npm:^4.0.3" gray-matter: "npm:^4.0.3"
hydra-synth: "npm:^1.3.29" hydra-synth: "npm:^1.3.29"
jest: "npm:^30.1.3" 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