From df959193b7eeb1b7b5bde947a7f8ebd6d15052a0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 6 Sep 2026 17:44:02 +0000 Subject: [PATCH 1/4] feat(site): add sticky custom cursor Lagging ink ring follows the pointer on fine-pointer devices, widens over links and buttons, and yields to text/grab cursors. Co-authored-by: froQ --- app/assets/css/main.css | 22 ++++ app/components/SiteStickyCursor.vue | 170 ++++++++++++++++++++++++++++ app/layouts/default.vue | 1 + app/utils/stickyCursor.ts | 36 ++++++ tests/stickyCursor.test.ts | 44 +++++++ 5 files changed, 273 insertions(+) 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..17377ea5 100644 --- a/app/assets/css/main.css +++ b/app/assets/css/main.css @@ -111,3 +111,25 @@ rt { [un-prose] .math-inline { --uno: 'text-[1.05em]'; } + +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; +} diff --git a/app/components/SiteStickyCursor.vue b/app/components/SiteStickyCursor.vue new file mode 100644 index 00000000..ada5c7f2 --- /dev/null +++ b/app/components/SiteStickyCursor.vue @@ -0,0 +1,170 @@ + + + + + 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/utils/stickyCursor.ts b/app/utils/stickyCursor.ts new file mode 100644 index 00000000..ee1dd1fb --- /dev/null +++ b/app/utils/stickyCursor.ts @@ -0,0 +1,36 @@ +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 type StickyCursorAim = 'idle' | 'hot' | 'native' + +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(STICKY_CURSOR_HOT_SEL)) + return 'hot' + return 'idle' +} + +export function stickyCursorCapable(): boolean { + if (typeof window === 'undefined') + return false + return window.matchMedia(STICKY_CURSOR_MQ).matches + && !window.matchMedia('(prefers-reduced-motion: reduce)').matches +} diff --git a/tests/stickyCursor.test.ts b/tests/stickyCursor.test.ts new file mode 100644 index 00000000..7ad499ab --- /dev/null +++ b/tests/stickyCursor.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest' +import { stickyCursorAim } 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() + }) +}) From e5ac6b0f653c1ec77327b3e8a1e78040417449e0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 6 Sep 2026 18:06:09 +0000 Subject: [PATCH 2/4] fix(site): enable sticky cursor on mouse pointers Some environments report hover:none even with a mouse. Treat pointerType mouse as capable unless reduced motion is on. Co-authored-by: froQ --- app/components/SiteStickyCursor.vue | 8 ++++++-- app/utils/stickyCursor.ts | 7 +++++-- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/app/components/SiteStickyCursor.vue b/app/components/SiteStickyCursor.vue index ada5c7f2..341d6c2e 100644 --- a/app/components/SiteStickyCursor.vue +++ b/app/components/SiteStickyCursor.vue @@ -61,8 +61,12 @@ function syncCapable() { } function onPointerMove(event: PointerEvent) { - if (!capable.value) - return + if (!capable.value) { + if (event.pointerType === 'mouse' && stickyCursorCapable('mouse')) + setCapable(true) + else + return + } if (leaveTimer) { window.clearTimeout(leaveTimer) leaveTimer = 0 diff --git a/app/utils/stickyCursor.ts b/app/utils/stickyCursor.ts index ee1dd1fb..17012530 100644 --- a/app/utils/stickyCursor.ts +++ b/app/utils/stickyCursor.ts @@ -28,9 +28,12 @@ export function stickyCursorAim(target: EventTarget | null): StickyCursorAim { return 'idle' } -export function stickyCursorCapable(): boolean { +export function stickyCursorCapable(pointerType?: string): boolean { if (typeof window === 'undefined') return false + if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) + return false + if (pointerType === 'mouse') + return true return window.matchMedia(STICKY_CURSOR_MQ).matches - && !window.matchMedia('(prefers-reduced-motion: reduce)').matches } From edc2c1dc53ab6707b5f7d212200a5c709a2ea054 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 6 Sep 2026 18:15:05 +0000 Subject: [PATCH 3/4] fix(site): make the sticky cursor actually visible The first pass used a white difference-blend ring that vanished on cream paper, and it stayed off when the OS asked to reduce motion. Draw an ink ring instead, keep it on with reduced motion (no lag), and inject cursor:none last so Uno pointer utilities cannot win. Co-authored-by: froQ --- app/components/SiteStickyCursor.vue | 120 ++++++++++++++++------------ app/utils/stickyCursor.ts | 49 +++++++++++- 2 files changed, 114 insertions(+), 55 deletions(-) diff --git a/app/components/SiteStickyCursor.vue b/app/components/SiteStickyCursor.vue index 341d6c2e..f0413b0a 100644 --- a/app/components/SiteStickyCursor.vue +++ b/app/components/SiteStickyCursor.vue @@ -1,9 +1,10 @@ diff --git a/app/utils/stickyCursor.ts b/app/utils/stickyCursor.ts index 17012530..0bae1a76 100644 --- a/app/utils/stickyCursor.ts +++ b/app/utils/stickyCursor.ts @@ -16,6 +16,45 @@ export const STICKY_CURSOR_NATIVE_SEL 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' export function stickyCursorAim(target: EventTarget | null): StickyCursorAim { @@ -28,12 +67,16 @@ export function stickyCursorAim(target: EventTarget | null): StickyCursorAim { return 'idle' } -export function stickyCursorCapable(pointerType?: string): boolean { +export function stickyCursorLag(): boolean { if (typeof window === 'undefined') return false - if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) + return !window.matchMedia('(prefers-reduced-motion: reduce)').matches +} + +export function stickyCursorCapable(pointerType?: string): boolean { + if (typeof window === 'undefined') return false - if (pointerType === 'mouse') + if (pointerType === 'mouse' || pointerType === 'pen') return true return window.matchMedia(STICKY_CURSOR_MQ).matches } From 2076450e4ff7b892c5d73e07952c68ebc13e0fcb Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 6 Sep 2026 18:21:10 +0000 Subject: [PATCH 4/4] ci(pages): deploy pull request previews Production still ships from main. PRs upload to a non-main Pages branch so a clickable preview exists before merge. Co-authored-by: froQ --- .github/workflows/deploy.yml | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 129158d5..24202b62 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -8,6 +8,9 @@ # Project: `froq` → https://froq.pages.dev # Custom domain: Dashboard → Pages → froq → Custom domains → froq.me # +# `main` deploys the production branch. Pull requests deploy a preview +# branch (not production) so the PR can be clicked before merge. +# # Create token: https://dash.cloudflare.com/profile/api-tokens # (Workers template + Cloudflare Pages:Edit is enough) @@ -16,15 +19,17 @@ name: Deploy Nuxt to Cloudflare Pages on: push: branches: [main] + pull_request: workflow_dispatch: permissions: contents: read deployments: write + pull-requests: write concurrency: - group: pages - cancel-in-progress: false + group: pages-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: deploy: @@ -52,8 +57,18 @@ jobs: NUXT_PUBLIC_FROQ_API: ${{ secrets.NUXT_PUBLIC_FROQ_API }} run: pnpm generate + - name: Resolve Pages branch + id: pages + run: | + if [ "${GITHUB_EVENT_NAME}" = "pull_request" ]; then + echo "branch=${GITHUB_HEAD_REF}" >> "$GITHUB_OUTPUT" + else + echo "branch=main" >> "$GITHUB_OUTPUT" + fi + # Use workspace wrangler (workers/froq-api) so the action does not # `pnpm add wrangler` under pnpm 11 (ERR_PNPM_IGNORED_BUILDS). + # Preview PRs must not pass --branch=main (that is production). - name: Deploy to Cloudflare Pages id: deploy uses: cloudflare/wrangler-action@v4 @@ -63,9 +78,14 @@ jobs: gitHubToken: ${{ secrets.GITHUB_TOKEN }} workingDirectory: workers/froq-api packageManager: pnpm - command: pages deploy ../../.output/public --project-name=froq --branch=main --commit-dirty=true + command: pages deploy ../../.output/public --project-name=froq --branch=${{ steps.pages.outputs.branch }} --commit-dirty=true - name: Deployment URL run: | echo "url: ${{ steps.deploy.outputs.deployment-url }}" echo "alias: ${{ steps.deploy.outputs.pages-deployment-alias-url }}" + { + echo "### Cloudflare Pages" + echo "- url: ${{ steps.deploy.outputs.deployment-url }}" + echo "- alias: ${{ steps.deploy.outputs.pages-deployment-alias-url }}" + } >> "$GITHUB_STEP_SUMMARY"