From fa5260f04a3d13b671ed512c7e377570c285480f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 7 Sep 2026 15:45:37 +0000 Subject: [PATCH] feat(site): sticky ink cursor with drawn arrow Idle is a dot. Hot targets swap to an SVG ink arrow. Rail links keep the nib and draw existing rough-ink instead of a pointer ring. Co-authored-by: froQ --- app/assets/css/main.css | 14 ++ app/assets/css/rough-ink.css | 19 +- app/components/SiteHub.vue | 12 +- app/components/SiteRailLink.vue | 2 + app/components/SiteStickyCursor.vue | 343 ++++++++++++++++++++++++++++ app/layouts/default.vue | 1 + app/pages/index.vue | 7 +- app/utils/inkDraw.ts | 17 +- app/utils/stickyCursor.ts | 198 ++++++++++++++++ tests/stickyCursor.test.ts | 79 +++++++ 10 files changed, 686 insertions(+), 6 deletions(-) create mode 100644 app/components/SiteStickyCursor.vue create mode 100644 app/utils/stickyCursor.ts create mode 100644 tests/stickyCursor.test.ts diff --git a/app/assets/css/main.css b/app/assets/css/main.css index eef7cd7a..c9b28e09 100644 --- a/app/assets/css/main.css +++ b/app/assets/css/main.css @@ -111,3 +111,17 @@ rt { [un-prose] .math-inline { --uno: 'text-[1.05em]'; } + +.home-fold-sheet { + pointer-events: none; +} + +.home-fold-sheet .home-fold-hit { + pointer-events: auto; +} + +@media (max-width: 759px) { + .home-fold-sheet { + pointer-events: auto; + } +} diff --git a/app/assets/css/rough-ink.css b/app/assets/css/rough-ink.css index ad14f6d5..c347e9d3 100644 --- a/app/assets/css/rough-ink.css +++ b/app/assets/css/rough-ink.css @@ -105,11 +105,26 @@ html.dark a[data-external] > .rough-ink[data-kind='mark'] { transition: visibility 0s; } -:is([data-hover-ink], [data-ink]):is(:hover, :focus-visible) > .rough-ink[data-reveal='hover'] .ink-reveal, -.ink-host:is(:hover, :focus-visible) [data-hover-ink] > .rough-ink[data-reveal='hover'] .ink-reveal { +html:not([data-sticky-cursor]) :is([data-hover-ink], [data-ink]):is(:hover, :focus-visible) > .rough-ink[data-reveal='hover'] .ink-reveal, +html[data-sticky-cursor] *:not([data-rail-ink]) > :is([data-hover-ink], [data-ink]):is(:hover, :focus-visible) > .rough-ink[data-reveal='hover'] .ink-reveal, +html:not([data-sticky-cursor]) .ink-host:is(:hover, :focus-visible) [data-hover-ink] > .rough-ink[data-reveal='hover'] .ink-reveal, +html[data-sticky-cursor] .ink-host:not([data-rail-ink]):is(:hover, :focus-visible) [data-hover-ink] > .rough-ink[data-reveal='hover'] .ink-reveal, +html[data-sticky-cursor] [data-rail-ink]:focus-visible [data-hover-ink] > .rough-ink[data-reveal='hover'] .ink-reveal { stroke-dashoffset: 0; } +html[data-sticky-cursor] [data-rail-ink]:hover [data-hover-ink] > .rough-ink[data-reveal='hover'], +html[data-sticky-cursor] [data-rail-ink][data-ink-draw] [data-hover-ink] > .rough-ink[data-reveal='hover'], +html[data-sticky-cursor] [data-rail-ink][data-ink-draw] [data-ink] > .rough-ink[data-reveal='hover'] { + visibility: visible; + transition: none; +} + +html[data-sticky-cursor] [data-rail-ink]:hover [data-hover-ink] > .rough-ink[data-reveal='hover'] .ink-reveal { + /* Pointer drawing is owned by JS; do not snap the stroke closed. */ + transition: none; +} + @media (prefers-reduced-motion: reduce) { .rough-ink[data-reveal='hover'], .rough-ink[data-reveal='hover'] .ink-reveal, diff --git a/app/components/SiteHub.vue b/app/components/SiteHub.vue index d200dfa2..23518223 100644 --- a/app/components/SiteHub.vue +++ b/app/components/SiteHub.vue @@ -66,7 +66,7 @@ function toggleLayers() { un-py-3 un-mx="[calc(-1*var(--hub-pad))]" un-px="[var(--hub-pad)]" - un-lg="fixed top-[8vh] right-[var(--hub-chrome-inset)] left-auto z-10 items-start gap-6 w-[var(--hub-side)] min-h-0 max-h-[calc(100dvh-8vh-6rem)] m-0 p-0 overflow-x-hidden overflow-y-auto border-0 bg-transparent backdrop-blur-none" + un-lg="fixed top-[8vh] right-[var(--hub-chrome-inset)] left-auto z-10 items-start gap-6 w-[var(--hub-side)] min-h-0 max-h-[calc(100dvh-8vh-6rem)] m-0 p-0 overflow-x-visible overflow-y-auto border-0 bg-transparent backdrop-blur-none" >
diff --git a/app/layouts/default.vue b/app/layouts/default.vue index 412e9dbf..344382d6 100644 --- a/app/layouts/default.vue +++ b/app/layouts/default.vue @@ -24,5 +24,6 @@ const hideChromeMast = computed(() => isHome.value || isHub.value) +
diff --git a/app/pages/index.vue b/app/pages/index.vue index b3dc8602..1137039d 100644 --- a/app/pages/index.vue +++ b/app/pages/index.vue @@ -166,6 +166,7 @@ const routes = [
-
+
diff --git a/app/utils/inkDraw.ts b/app/utils/inkDraw.ts index d0273e67..4f501203 100644 --- a/app/utils/inkDraw.ts +++ b/app/utils/inkDraw.ts @@ -106,7 +106,7 @@ export function inkArrowPath(seed: string, dir: InkArrowDir = 'up'): string { return inkStrokePath(pts) } -export type InkPointerParts = { +export interface InkPointerParts { stem: string head: string } @@ -144,6 +144,21 @@ export function inkPointerPath(seed: string, dir: InkArrowDir = 'down'): string return `${stem} ${head}` } +/** Compact mouse pointer. ViewBox 0 0 32 32. Hotspot at ~(2, 2). */ +export function inkCursorArrowParts(seed: string): InkPointerParts { + const rng = inkRng(`ink-cursor:v1:${seed}`) + const j = (span = 0.7) => (rng() - 0.5) * span + const tip: InkPt = [2.1 + j(0.2), 2.1 + j(0.2)] + const shaft: InkPt = [8.4 + j(), 10.6 + j()] + const tail: InkPt = [13.8 + j(), 18.4 + j()] + const left: InkPt = [tip[0] + 7.2 + j(0.8), tip[1] + 1.8 + j(0.6)] + const right: InkPt = [tip[0] + 1.8 + j(0.6), tip[1] + 7.2 + j(0.8)] + return { + stem: inkStrokePath([tip, shaft, tail]), + head: inkStrokePath([left, tip, right]), + } +} + export type InkGlyphKind = 'mail' | 'wechat' diff --git a/app/utils/stickyCursor.ts b/app/utils/stickyCursor.ts new file mode 100644 index 00000000..10855c0d --- /dev/null +++ b/app/utils/stickyCursor.ts @@ -0,0 +1,198 @@ +import { paintRoughInk } from '~/utils/roughInk' + +export const STICKY_CURSOR_FIELD_SEL + = 'input, textarea, select, [contenteditable]:not([contenteditable="false"])' + +export const STICKY_CURSOR_HOT_SEL = [ + 'a[href]', + 'button:not([disabled])', + '[role="button"]', + 'summary', + 'label[for]', + '[un-cursor-pointer]', +].join(', ') + +export const STICKY_CURSOR_NATIVE_SEL + = `${STICKY_CURSOR_FIELD_SEL}, [un-cursor*="grab"], iframe` + +export const STICKY_CURSOR_MQ + = '(pointer: fine) and (hover: hover)' + +export const STICKY_CURSOR_STYLE_ID = 'sticky-cursor-css' + +const STICKY_CURSOR_CSS = ` +html[data-sticky-cursor], +html[data-sticky-cursor] * { + cursor: none !important; +} +html[data-sticky-cursor] :is( + input, + textarea, + select, + [contenteditable]:not([contenteditable='false']) +) { + cursor: text !important; +} +html[data-sticky-cursor] [un-cursor*='grab'] { + cursor: grab !important; +} +html[data-sticky-cursor] [un-cursor*='grab']:active { + cursor: grabbing !important; +} +` + +export function paintStickyCursorCss(on: boolean) { + if (typeof document === 'undefined') + return + const existing = document.getElementById(STICKY_CURSOR_STYLE_ID) + if (!on) { + existing?.remove() + return + } + if (existing) + return + const style = document.createElement('style') + style.id = STICKY_CURSOR_STYLE_ID + style.textContent = STICKY_CURSOR_CSS + document.head.appendChild(style) +} + +export type StickyCursorAim = 'idle' | 'hot' | 'native' | 'ink' +export type StickyRailInkKind = 'underline' | 'mark' | 'circle' + +const STICKY_CURSOR_CHROME + = '.sticky-cursor-dot, .sticky-cursor-nib, .sticky-cursor-arrow' + +export function stickyCursorHit(x: number, y: number, fallback: EventTarget | null): Element | null { + if (typeof document !== 'undefined') { + const stack = document.elementsFromPoint(x, y) + for (const el of stack) { + if (!(el instanceof Element)) + continue + if (el.closest(STICKY_CURSOR_CHROME)) + continue + return el + } + } + return fallback instanceof Element ? fallback : null +} + +export function stickyCursorRailHost(target: EventTarget | null): HTMLElement | null { + if (!(target instanceof Element)) + return null + return target.closest('[data-rail-ink]') +} + +export function stickyCursorRailInkKind(host: HTMLElement): StickyRailInkKind | undefined { + const label = host.querySelector('[data-hover-ink], [data-ink]') + const value = label?.dataset.hoverInk || label?.dataset.ink + if (value === 'underline' || value === 'mark' || value === 'circle') + return value +} + +export function stickyRailInkAmount( + kind: StickyRailInkKind, + box: Pick, + x: number, + y: number, + travel: number, +): number { + switch (kind) { + case 'underline': + case 'mark': { + const t = (x - box.left) / Math.max(box.width, 1) + return Math.min(1, Math.max(0, t)) + } + case 'circle': { + const cx = box.left + box.width / 2 + const cy = box.top + box.height / 2 + let ang = Math.atan2(x - cx, -(y - cy)) + if (ang < 0) + ang += Math.PI * 2 + const peri = Math.PI * Math.max(box.width + box.height, 1) + const byTravel = travel / peri + return Math.min(1, Math.max(ang / (Math.PI * 2), byTravel)) + } + default: { + const _exhaustive: never = kind + return _exhaustive + } + } +} + +function railInkLabel(host: HTMLElement): HTMLElement | null { + return host.querySelector('[data-hover-ink], [data-ink]') +} + +function railInkSvg(host: HTMLElement): SVGSVGElement | null { + const label = railInkLabel(host) + if (!label) + return null + if (!label.querySelector(':scope > .rough-ink')) + paintRoughInk(label) + return label.querySelector(':scope > .rough-ink[data-reveal="hover"]') + ?? label.querySelector(':scope > .rough-ink') +} + +function railInkBox(host: HTMLElement): DOMRect { + const label = host.querySelector('[data-hover-ink], [data-ink]') + return (label ?? host).getBoundingClientRect() +} + +export function scrubStickyRailInk(host: HTMLElement, x: number, y: number, travel: number): StickyRailInkKind | undefined { + const kind = stickyCursorRailInkKind(host) + const svg = railInkSvg(host) + const path = svg?.querySelector('.ink-reveal') ?? null + if (!kind || !svg || !path) + return kind + host.dataset.inkDraw = '' + svg.style.setProperty('visibility', 'visible', 'important') + svg.style.transition = 'none' + const len = Number.parseFloat(path.style.getPropertyValue('--ink-len')) || path.getTotalLength() + const amount = stickyRailInkAmount(kind, railInkBox(host), x, y, travel) + path.style.transition = 'none' + path.style.strokeDasharray = `${len}` + path.style.setProperty('stroke-dashoffset', `${len * (1 - amount)}`) + return kind +} + +export function releaseStickyRailInk(host: HTMLElement): void { + const svg = railInkSvg(host) + const path = svg?.querySelector('.ink-reveal') ?? null + delete host.dataset.inkDraw + if (svg) { + svg.style.removeProperty('visibility') + svg.style.removeProperty('transition') + } + if (!path) + return + const len = Number.parseFloat(path.style.getPropertyValue('--ink-len')) || path.getTotalLength() + path.style.transition = 'stroke-dashoffset 0.55s var(--ease-out, ease)' + path.style.strokeDashoffset = `${len}` +} + +export function stickyCursorAim(target: EventTarget | null): StickyCursorAim { + if (!(target instanceof Element)) + return 'idle' + if (target.closest(STICKY_CURSOR_NATIVE_SEL)) + return 'native' + if (target.closest('[data-rail-ink]')) + return 'ink' + if (target.closest(STICKY_CURSOR_HOT_SEL)) + return 'hot' + return 'idle' +} + +export function stickyCursorLag(): boolean { + if (typeof window === 'undefined') + return false + return !window.matchMedia('(prefers-reduced-motion: reduce)').matches +} + +export function stickyCursorCapable(pointerType?: string): boolean { + if (typeof window === 'undefined') + return false + if (pointerType === 'mouse' || pointerType === 'pen') + return true + return window.matchMedia(STICKY_CURSOR_MQ).matches +} diff --git a/tests/stickyCursor.test.ts b/tests/stickyCursor.test.ts new file mode 100644 index 00000000..fc0e0dda --- /dev/null +++ b/tests/stickyCursor.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'vitest' +import { inkCursorArrowParts } from '../app/utils/inkDraw' +import { stickyCursorAim, stickyRailInkAmount } from '../app/utils/stickyCursor' + +describe('stickyCursorAim', () => { + it('returns idle for empty targets', () => { + expect(stickyCursorAim(null)).toBe('idle') + }) + + it('marks links and buttons hot', () => { + const a = document.createElement('a') + a.href = '/posts' + document.body.append(a) + expect(stickyCursorAim(a)).toBe('hot') + a.remove() + + const btn = document.createElement('button') + document.body.append(btn) + expect(stickyCursorAim(btn)).toBe('hot') + btn.remove() + }) + + it('keeps native cursor on fields and grab handles', () => { + const input = document.createElement('input') + document.body.append(input) + expect(stickyCursorAim(input)).toBe('native') + input.remove() + + const pin = document.createElement('div') + pin.setAttribute('un-cursor', 'grab active:grabbing') + document.body.append(pin) + expect(stickyCursorAim(pin)).toBe('native') + pin.remove() + }) + + it('lets native win over a wrapping link', () => { + const a = document.createElement('a') + a.href = '/' + const field = document.createElement('textarea') + a.append(field) + document.body.append(a) + expect(stickyCursorAim(field)).toBe('native') + a.remove() + }) + + it('treats home rail links as ink, not hot', () => { + const a = document.createElement('a') + a.href = '/posts' + a.setAttribute('data-rail-ink', '') + document.body.append(a) + expect(stickyCursorAim(a)).toBe('ink') + a.remove() + }) +}) + +describe('inkCursorArrowParts', () => { + it('returns a stable ink arrow for a given seed', () => { + const a = inkCursorArrowParts('sticky-cursor') + expect(a.head.startsWith('M')).toBe(true) + expect(a.stem.startsWith('M')).toBe(true) + expect(inkCursorArrowParts('sticky-cursor')).toEqual(a) + expect(inkCursorArrowParts('other')).not.toEqual(a) + }) +}) + +describe('stickyRailInkAmount', () => { + const box = { left: 0, top: 0, width: 100, height: 40 } + + it('maps underline and mark to horizontal progress', () => { + expect(stickyRailInkAmount('underline', box, 0, 20, 0)).toBe(0) + expect(stickyRailInkAmount('mark', box, 50, 20, 0)).toBe(0.5) + expect(stickyRailInkAmount('underline', box, 100, 20, 0)).toBe(1) + }) + + it('maps circle to angle from the top, clockwise', () => { + expect(stickyRailInkAmount('circle', box, 50, 0, 0)).toBe(0) + expect(stickyRailInkAmount('circle', box, 100, 20, 0)).toBe(0.25) + }) +})