@ash-qw/dsh-theme-prts 2.6.0 → 2.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,271 @@
1
+ import MouseFollower from 'mouse-follower'
2
+ import { gsap } from 'gsap'
3
+ import { createCursorSemantics } from './cursor-semantics.js'
4
+ import { createCursorAdapter as createNativeCursorAdapter } from './cursor-adapter.js'
5
+
6
+ // Mouse Follower owns the isolated DOM renderer. Use our Pointer Events lifecycle
7
+ // instead of its mouse/tween listeners: no easing, axis-dependent ticker or idle loop.
8
+ class DirectFollower extends MouseFollower {
9
+ bind() {}
10
+ }
11
+ MouseFollower.registerGSAP(gsap)
12
+
13
+ export function createCursorAdapter({document, window, assets}) {
14
+ const native = createNativeCursorAdapter({document, window, assets})
15
+ const root = document.documentElement
16
+ let follower, img, point, moveFrame, timer, family, resting, pressed = false, phase
17
+ let semantics, captureTarget, layerContext, stateObserver, watchedTarget, nativePicker
18
+ let textContext, renderedX, renderedY
19
+ let eventTarget, semanticTarget, semanticValue, pose, poseKind, dirty = true, hitDirty = true
20
+ let suspended = false, visible = false, currentUrl, ready = false
21
+ const staticPose=(rest,hotspot)=>({rest,hotspot})
22
+ const vectorPose=path=>staticPose('data:image/svg+xml,'+encodeURIComponent(
23
+ '<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32"><path d="'+path+'" fill="none" stroke="#101820" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/><path d="'+path+'" fill="none" stroke="#eee8dc" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>'),[16,16])
24
+
25
+ const poses={
26
+ move:vectorPose('M16 3V29M3 16H29M12 7L16 3L20 7M12 25L16 29L20 25M7 12L3 16L7 20M25 12L29 16L25 20'),
27
+ crosshair:vectorPose('M16 4V12M16 20V28M4 16H12M20 16H28M16 15V17'),
28
+ text:staticPose(assets?.text,assets?.textHotspot),grab:staticPose(assets?.grab,[22,19]),
29
+ grabbing:staticPose(assets?.grabbing,[22,19]),unavailable:staticPose(assets?.unavailable,[7,7]),
30
+ ...Object.fromEntries(Object.entries(assets?.resize || {}).map(([key,url])=>[key,staticPose(url,[16,16])])),
31
+ }
32
+ const resizeKinds={'n-resize':'ns','s-resize':'ns','ns-resize':'ns','row-resize':'ns',
33
+ 'e-resize':'ew','w-resize':'ew','ew-resize':'ew','col-resize':'ew',
34
+ 'ne-resize':'nesw','sw-resize':'nesw','nesw-resize':'nesw',
35
+ 'nw-resize':'nwse','se-resize':'nwse','nwse-resize':'nwse'}
36
+ const reduced = () => root.dataset.prtsMotion === 'reduced' || window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
37
+ function stop() {
38
+ if (timer !== undefined) window.clearTimeout(timer)
39
+ timer = undefined
40
+ pressed = false
41
+ phase = undefined
42
+ }
43
+ function hide() {
44
+ stop()
45
+ if (follower) {
46
+ if(follower.el.matches(':popover-open'))follower.el.hidePopover()
47
+ follower.el.hidden = true
48
+ }
49
+ root.removeAttribute('data-prts-follower-visible')
50
+ semantics?.release()
51
+ layerContext=undefined
52
+ stateObserver?.disconnect();watchedTarget=undefined
53
+ visible = false
54
+ semanticTarget=pose=poseKind=textContext=renderedX=renderedY=undefined;dirty=true
55
+ }
56
+ function draw(url) {
57
+ if (!img || !url || currentUrl === url) return
58
+ currentUrl = url
59
+ img.src = url
60
+ }
61
+ function play(frames, done) {
62
+ if(timer !== undefined) window.clearTimeout(timer)
63
+ const started = window.performance.now()
64
+ const tick = () => {
65
+ timer = undefined
66
+ if (!visible) return
67
+ const elapsed = window.performance.now()-started
68
+ let index=0, deadline=frames[0].duration
69
+ while(index<frames.length-1 && elapsed>=deadline) deadline+=frames[++index].duration
70
+ if(index===frames.length-1){done();return}
71
+ draw(frames[index].url)
72
+ timer=window.setTimeout(tick, Math.max(1,deadline-(window.performance.now()-started)))
73
+ }
74
+ tick()
75
+ }
76
+ function release() {
77
+ phase='release'
78
+ play(family.release,()=>{stop();sync()})
79
+ }
80
+ function sync() {
81
+ if (!follower || !ready || !point || nativePicker || suspended || document.hidden || document.pointerLockElement) return hide()
82
+ const hit=hitDirty || !eventTarget?.isConnected ? document.elementFromPoint(point.x,point.y) : eventTarget
83
+ eventTarget=hit;hitDirty=false
84
+ if(!hit || hit.closest('iframe'))return hide()
85
+ const target=captureTarget?.isConnected?captureTarget:hit
86
+ if(target!==watchedTarget){
87
+ stateObserver.disconnect();watchedTarget=target
88
+ for(let el=target;el;el=el.parentElement)stateObserver.observe(el,{attributes:true,
89
+ attributeFilter:['style','class','disabled','aria-disabled','open','data-dragging','data-prts-emblem-sort-active','data-markdown','data-slot','data-phase','data-chat-flow-kind','data-message-role','data-submission-echo','data-pending-steering']})
90
+ }
91
+ if(dirty || target!==semanticTarget){
92
+ semanticValue=semantics.read(target)
93
+ textContext=undefined
94
+ semanticTarget=target;dirty=false
95
+ }
96
+ const raw=semanticValue
97
+ const kind=raw.split(',').at(-1).trim()
98
+ let nextFamily=assets
99
+ if(kind==='none'){
100
+ stop();follower.el.hidden=true
101
+ root.setAttribute('data-prts-follower-visible','');visible=true
102
+ return
103
+ }
104
+ if(kind==='pointer')nextFamily=assets.link
105
+ else if(['text','vertical-text'].includes(kind))nextFamily=poses.text
106
+ else if(resizeKinds[kind])nextFamily=poses[resizeKinds[kind]]
107
+ else if(['grab','grabbing','move','crosshair','cell'].includes(kind))nextFamily=poses[kind==='cell'?'crosshair':kind]
108
+ else if(kind==='not-allowed'||kind==='no-drop')nextFamily=poses.unavailable
109
+ else {
110
+ textContext ||= native.prepareTextHit(target)
111
+ if(native.textHit({target,clientX:point.x,clientY:point.y,pointerType:'mouse'},textContext))nextFamily=poses.text
112
+ }
113
+ if(family!==nextFamily && (!phase || !family?.press || !nextFamily.press)){stop();family=nextFamily}
114
+ resting=family.rest
115
+ const [x,y]=family.hotspot
116
+ if(pose!==family || poseKind!==kind){
117
+ img.style.left=-x+'px';img.style.top=-y+'px'
118
+ img.style.transform=kind==='vertical-text'?'rotate(90deg)':''
119
+ pose=family;poseKind=kind
120
+ }
121
+ if(!phase)draw(resting)
122
+ if(renderedX!==point.x || renderedY!==point.y){
123
+ follower.pos.x=point.x;follower.pos.y=point.y
124
+ follower.render(true)
125
+ renderedX=point.x;renderedY=point.y
126
+ }
127
+ follower.el.hidden=false
128
+ // A manual popover is non-interactive and stays above modal/top-layer UI.
129
+ const context=hit.closest('dialog,[popover]') || document.fullscreenElement
130
+ if(follower.el.showPopover && (!visible || context!==layerContext)){
131
+ if(context!==layerContext && follower.el.matches(':popover-open'))follower.el.hidePopover()
132
+ if(!follower.el.matches(':popover-open'))follower.el.showPopover()
133
+ }
134
+ layerContext=context
135
+ if(!visible)root.setAttribute('data-prts-follower-visible','')
136
+ visible=true
137
+ }
138
+ function flush() { moveFrame=undefined;sync() }
139
+ function move(event) {
140
+ if(event.pointerType!=='mouse')return
141
+ resumePicker(event)
142
+ if(suspended && !event.buttons)suspended=false
143
+ const unchanged=point?.x===event.clientX && point?.y===event.clientY && eventTarget===event.target
144
+ eventTarget=event.target;hitDirty=false
145
+ point={x:event.clientX,y:event.clientY}
146
+ if(pressed && !(event.buttons&1)){stop();draw(resting)}
147
+ if(!unchanged || dirty)if(moveFrame===undefined)moveFrame=window.requestAnimationFrame(flush)
148
+ }
149
+ function down(event) {
150
+ if(event.pointerType!=='mouse'){reset();return}
151
+ if(event.button!==0||event.isPrimary===false)return
152
+ const picker=event.target.closest?.('select,input[type="file"],input[type="color"]')
153
+ if(picker){nativePicker=picker;reset();return}
154
+ nativePicker=undefined
155
+ stop();dirty=true;eventTarget=event.target;hitDirty=false;point={x:event.clientX,y:event.clientY};sync()
156
+ if(!visible || follower.el.hidden)return
157
+ if(!family?.press)return
158
+ stop();pressed=true
159
+ if(reduced()){phase='hold';draw(family.hold);return}
160
+ phase='press'
161
+ play(family.press,()=>{
162
+ phase='hold';draw(family.hold)
163
+ if(!pressed)timer=window.setTimeout(release,48)
164
+ })
165
+ }
166
+ // Boundary updates only change the shared renderer's image; the browser
167
+ // cursor stays hidden throughout. Ordinary movement remains coalesced.
168
+ // Native pickers do not expose a reliable close event (including Escape).
169
+ // Resume only on pointer activity outside their owning control; refreshes
170
+ // and pointer activity on the select must never resurrect the overlay.
171
+ function resumePicker(event){
172
+ if(nativePicker && !nativePicker.contains(event.target))nativePicker=undefined
173
+ }
174
+ function keydown(event){
175
+ const picker=event.target.closest?.('select,input[type="file"],input[type="color"]')
176
+ if(picker && [' ','Enter','ArrowDown','ArrowUp','F4'].includes(event.key)){nativePicker=picker;reset()}
177
+ }
178
+ function over(event) {
179
+ if(event.pointerType!=='mouse')return
180
+ resumePicker(event)
181
+ if(moveFrame!==undefined)window.cancelAnimationFrame(moveFrame)
182
+ moveFrame=undefined
183
+ dirty=true;eventTarget=event.target;hitDirty=false
184
+ point={x:event.clientX,y:event.clientY}
185
+ sync()
186
+ }
187
+ function up(event) {
188
+ if(event.pointerType!=='mouse'||event.button!==0||!pressed)return
189
+ pressed=false;dirty=true
190
+ if(reduced()){stop();draw(resting);return}
191
+ if(phase!=='press')release()
192
+ }
193
+ function reset() {
194
+ if(moveFrame!==undefined)window.cancelAnimationFrame(moveFrame)
195
+ moveFrame=undefined;point=undefined;eventTarget=undefined;hitDirty=true;captureTarget=undefined;hide()
196
+ }
197
+ function blur(event){if(event.target===window)reset()}
198
+ function leave(event){if(!event.relatedTarget || event.relatedTarget.closest?.('iframe'))reset()}
199
+ function drag(){suspended=true;reset()}
200
+ function visibility(){if(document.hidden)reset()}
201
+ // Passive changes can also move elements beneath a stationary pointer.
202
+ // Re-hit then; ordinary pointer events already carry the browser's result.
203
+ function refresh(){
204
+ dirty=true;hitDirty=true
205
+ if(point && moveFrame===undefined)moveFrame=window.requestAnimationFrame(flush)
206
+ }
207
+ function capture(event){captureTarget=event.target;refresh()}
208
+ function uncapture(event){captureTarget=undefined;refresh(event)}
209
+ const events=[['pointermove',move],['pointerover',over],['pointerdown',down],['pointerup',up],
210
+ ['pointercancel',reset],['pointerout',leave],['blur',blur],['dragstart',drag],
211
+ ['scroll',refresh],['resize',refresh],['focusin',refresh],['click',refresh],
212
+ ['gotpointercapture',capture],['lostpointercapture',uncapture],['contextmenu',drag],['keydown',keydown]]
213
+ function disposeFollower(){
214
+ reset()
215
+ for(const [name,fn]of events)window.removeEventListener(name,fn,true)
216
+ document.removeEventListener('visibilitychange',visibility)
217
+ document.removeEventListener('fullscreenchange',reset)
218
+ document.removeEventListener('pointerlockchange',reset)
219
+ if(follower){
220
+ window.clearTimeout(follower.visibleInt)
221
+ gsap.killTweensOf(follower.pos)
222
+ follower.destroy()
223
+ }
224
+ semantics?.dispose();semantics=undefined
225
+ stateObserver?.disconnect();stateObserver=watchedTarget=undefined
226
+ follower=img=family=currentUrl=undefined
227
+ ready=false
228
+ suspended=false
229
+ nativePicker=undefined
230
+ }
231
+ return {
232
+ update(preferences){
233
+ const experimental=preferences.enabled && preferences.cursor==='prts-follower'
234
+ native.update({...preferences,enabled:preferences.enabled && !experimental})
235
+ if(!experimental){disposeFollower();return}
236
+ if(follower)return
237
+ follower=new DirectFollower({container:document.body,eventsTarget:document.documentElement,
238
+ className:'prts-cursor-follower',innerClassName:'prts-cursor-follower-inner',
239
+ speed:0,ease:'none',skewing:0,stateDetection:{},dataAttr:null,
240
+ visible:false,hideOnLeave:false,showTimeout:0,hideTimeout:0})
241
+ gsap.ticker.remove(follower.ticker)
242
+ stateObserver=new window.MutationObserver(refresh)
243
+ semantics=createCursorSemantics({document,window,onChange:refresh})
244
+ follower.el.setAttribute('popover','manual')
245
+ follower.el.hidden=true
246
+ follower.el.setAttribute('aria-hidden','true')
247
+ // Keep the native cursor until decoding finishes; setting src alone
248
+ // does not guarantee the first displayed frame is ready to paint.
249
+ const instance=follower, decoded=[]
250
+ for(const group of [assets,assets.link])for(const url of [group.rest,group.hold,...group.press.map(f=>f.url),...group.release.map(f=>f.url)]){
251
+ const image=new window.Image();image.src=url
252
+ decoded.push(image.decode())
253
+ }
254
+ for(const url of [...Object.values(assets.resize || {}),assets.text,assets.grab,assets.grabbing,assets.unavailable,poses.move.rest,poses.crosshair.rest]){
255
+ const image=new window.Image();image.src=url;decoded.push(image.decode())
256
+ }
257
+ img=document.createElement('img');img.alt='';img.draggable=false
258
+ follower.inner.append(img)
259
+ for(const [name,fn]of events)window.addEventListener(name,fn,true)
260
+ document.addEventListener('visibilitychange',visibility)
261
+ document.addEventListener('fullscreenchange',reset)
262
+ document.addEventListener('pointerlockchange',reset)
263
+ Promise.all(decoded).then(()=>{
264
+ if(follower!==instance)return
265
+ ready=true
266
+ sync()
267
+ }).catch(()=>{if(follower===instance)hide()})
268
+ },
269
+ dispose(){disposeFollower();native.dispose()},
270
+ }
271
+ }
@@ -0,0 +1,74 @@
1
+ // Preserve the host's cursor cascade as metadata while the experimental
2
+ // renderer suppresses the browser cursor. No hide/read/show cycle per move.
3
+ export function createCursorSemantics({document, window, onChange = () => {}}) {
4
+ const property='--prts-source-cursor'
5
+ const sheet=document.createElement('style')
6
+ sheet.dataset.prtsCursorSemantics=''
7
+ const inline=new Map()
8
+ let pending, disposed=false
9
+ function serialize(rules) {
10
+ let result=''
11
+ for(const rule of rules){
12
+ if(rule.selectorText?.includes('data-prts-follower-visible'))continue
13
+ const value=rule.style?.getPropertyValue('cursor')
14
+ const own=value?`${property}:${value}${rule.style.getPropertyPriority('cursor')?' !important':''};`:''
15
+ const children=rule.cssRules?serialize(rule.cssRules):''
16
+ if(!own&&!children)continue
17
+ if(rule.selectorText)result+=`${rule.selectorText}{${own}${children}}`
18
+ else if(rule.conditionText || /^@(media|supports|layer|container|scope)\b/.test(rule.cssText))result+=`${rule.cssText.slice(0,rule.cssText.indexOf('{'))}{${children}}`
19
+ }
20
+ return result
21
+ }
22
+ function rebuild(){
23
+ pending=undefined
24
+ if(disposed)return
25
+ let text=''
26
+ for(const source of document.styleSheets){
27
+ if(source.ownerNode===sheet || source.disabled)continue
28
+ try{
29
+ const rules=serialize(source.cssRules)
30
+ const media=source.media?.mediaText
31
+ text+=media?`@media ${media}{${rules}}`:rules
32
+ }catch{ /* Cross-origin sheets are not readable; semantic fallbacks remain. */ }
33
+ }
34
+ if(sheet.textContent!==text){sheet.textContent=text;onChange()}
35
+ }
36
+ function schedule(){if(!disposed && pending===undefined)pending=window.requestAnimationFrame(rebuild)}
37
+ function loaded(event){if(event.target?.matches?.('link[rel~="stylesheet"]'))schedule()}
38
+ const observer=new window.MutationObserver(records=>{
39
+ if(records.some(r=>r.target!==sheet && !sheet.contains(r.target)))schedule()
40
+ })
41
+ document.head.append(sheet)
42
+ rebuild()
43
+ observer.observe(document.head,{subtree:true,childList:true,characterData:true,attributes:true,attributeFilter:['disabled','media','href']})
44
+ document.addEventListener('load',loaded,true)
45
+ function restore(el,record){
46
+ if(el.style.getPropertyValue(property)===record.applied){
47
+ if(record.original)el.style.setProperty(property,record.original,record.priority)
48
+ else el.style.removeProperty(property)
49
+ }
50
+ if(record.masked && el.style.cursor==='none' && el.style.getPropertyPriority('cursor')==='important')el.style.setProperty('cursor',record.source,'important')
51
+ }
52
+ function read(target){
53
+ // Inline cursor updates (e.g. a body-wide resize gesture) outrank stylesheet
54
+ // rules. Mirror only the hit ancestry, without scanning the subtree.
55
+ const ancestry=new Set()
56
+ for(let el=target;el;el=el.parentElement){
57
+ ancestry.add(el)
58
+ let value=el.style.getPropertyValue('cursor'),priority=el.style.getPropertyPriority('cursor')
59
+ const old=inline.get(el)
60
+ if(old?.masked && value==='none' && priority==='important')value=old.source
61
+ if(!value){if(old){restore(el,old);inline.delete(el)}continue}
62
+ if(old && old.source===value && old.sourcePriority===priority)continue
63
+ if(old)restore(el,old)
64
+ const record={original:el.style.getPropertyValue(property),priority:el.style.getPropertyPriority(property),source:value,sourcePriority:priority,applied:value,masked:priority==='important'}
65
+ el.style.setProperty(property,value,priority)
66
+ if(record.masked)el.style.setProperty('cursor','none','important')
67
+ inline.set(el,record)
68
+ }
69
+ for(const [el,record]of inline)if(!ancestry.has(el)){restore(el,record);inline.delete(el)}
70
+ return window.getComputedStyle(target).getPropertyValue(property).trim()
71
+ }
72
+ function release(){for(const [el,record]of inline)restore(el,record);inline.clear()}
73
+ return {read,release,dispose(){disposed=true;observer.disconnect();document.removeEventListener('load',loaded,true);if(pending!==undefined)window.cancelAnimationFrame(pending);release();sheet.remove()}}
74
+ }
@@ -0,0 +1,185 @@
1
+ // Keep the existing form values/events as the model; render interactive UI in
2
+ // the page so native popup windows never take ownership of the cursor.
3
+ export function mountPageControls({panel,window}){
4
+ // Keep native controls usable in hosts without the popover API.
5
+ if(!panel || !window || typeof window.HTMLElement.prototype.showPopover!=='function')return {dispose(){}}
6
+ const document=panel.ownerDocument, cleanups=[]
7
+ let active, tooltip, tipTarget, originalDescription, restoringFocus=false, sequence=0
8
+ const hideTip=()=>{
9
+ if(!tooltip)return
10
+ tooltip.hidePopover();tooltip.remove();tooltip=undefined
11
+ if(originalDescription===null)tipTarget.removeAttribute('aria-describedby')
12
+ else tipTarget.setAttribute('aria-describedby',originalDescription)
13
+ tipTarget=undefined
14
+ }
15
+ const showTip=(target,text,anchor=target)=>{
16
+ hideTip()
17
+ if(!text)return
18
+ tooltip=document.createElement('div');tooltip.className='prts-page-tooltip'
19
+ tooltip.setAttribute('popover','manual');tooltip.setAttribute('role','tooltip')
20
+ tooltip.id='prts-control-tip-'+(++sequence);tooltip.textContent=text
21
+ tipTarget=target;originalDescription=target.getAttribute('aria-describedby')
22
+ target.setAttribute('aria-describedby',tooltip.id)
23
+ const container=target.closest('dialog') || panel;container.append(tooltip);tooltip.showPopover()
24
+ const rect=anchor.getBoundingClientRect(),size=tooltip.getBoundingClientRect()
25
+ const right=anchor!==target && rect.right+size.width+8<=window.innerWidth-8
26
+ tooltip.style.left=Math.max(8,Math.min(right?rect.right+8:rect.left,window.innerWidth-size.width-8))+'px'
27
+ const top=right?rect.top:rect.bottom+size.height+14<=window.innerHeight?rect.bottom+6:rect.top-size.height-6
28
+ tooltip.style.top=Math.max(8,Math.min(top,window.innerHeight-size.height-8))+'px'
29
+ }
30
+ const close=(focus=false)=>{
31
+ hideTip()
32
+ if(!active)return
33
+ const {popup,button}=active;active=undefined
34
+ popup.hidePopover();popup.remove();button.setAttribute('aria-expanded','false');button.removeAttribute('aria-controls')
35
+ if(focus){
36
+ restoringFocus=true
37
+ try{button.focus({preventScroll:true})}finally{restoringFocus=false}
38
+ }
39
+ }
40
+ const open=(button,role)=>{
41
+ close()
42
+ const popup=document.createElement('div')
43
+ popup.className='prts-page-popup';popup.setAttribute('popover','manual')
44
+ popup.setAttribute('role',role);popup.id='prts-page-popup-'+(++sequence)
45
+ popup.setAttribute('aria-label',button.getAttribute('aria-label'))
46
+ const container=button.closest('dialog') || panel
47
+ container.append(popup)
48
+ button.setAttribute('aria-controls',popup.id);button.setAttribute('aria-expanded','true')
49
+ active={popup,button}
50
+ popup.addEventListener('keydown',e=>{
51
+ e.stopPropagation()
52
+ if(e.key==='Escape'){e.preventDefault();close(true)}
53
+ if(e.key==='Tab' && role==='listbox')close(true)
54
+ })
55
+ return popup
56
+ }
57
+ const present=(popup,button)=>{
58
+ popup.showPopover()
59
+ const rect=button.getBoundingClientRect()
60
+ popup.style.minWidth=Math.min(260,Math.max(160,rect.width))+'px'
61
+ const size=popup.getBoundingClientRect()
62
+ popup.style.left=Math.max(8,Math.min(rect.left,window.innerWidth-size.width-8))+'px'
63
+ popup.style.top=Math.max(8,rect.bottom+size.height+6>window.innerHeight?rect.top-size.height-6:rect.bottom+6)+'px'
64
+ }
65
+ for(const control of panel.querySelectorAll('select,input[type="color"][data-prts-setting-color]')){
66
+ // A label forwards both hover and activation to its first button, making
67
+ // the entire settings row look clickable. These controls name themselves.
68
+ const label=control.parentElement
69
+ if(label.tagName==='LABEL'){
70
+ const row=document.createElement('div')
71
+ for(const {name,value} of label.attributes)row.setAttribute(name,value)
72
+ row.setAttribute('data-prts-page-control-row','')
73
+ row.append(...label.childNodes);label.replaceWith(row)
74
+ cleanups.push(()=>{label.append(...row.childNodes);row.replaceWith(label)})
75
+ }
76
+ const isSelect=control.tagName==='SELECT'
77
+ const button=document.createElement('button')
78
+ button.type='button';button.className='prts-page-control'
79
+ button.setAttribute('role',isSelect?'combobox':'button')
80
+ button.setAttribute('aria-label',control.getAttribute('aria-label') || control.parentElement.firstChild?.textContent?.trim() || '选择')
81
+ button.setAttribute('aria-haspopup',isSelect?'listbox':'dialog')
82
+ button.setAttribute('aria-expanded','false')
83
+ if(control.hasAttribute('aria-describedby'))button.setAttribute('aria-describedby',control.getAttribute('aria-describedby'))
84
+ const caption=document.createElement('span');caption.className='prts-page-caption';button.append(caption)
85
+ if(!isSelect && control.closest('[data-prts-session-flow-color-inputs]')){
86
+ const row=control.parentElement, name=row.querySelector('span'), value=row.querySelector('output')
87
+ const meta=document.createElement('span');meta.className='prts-page-color-meta'
88
+ if(name)meta.append(name)
89
+ if(value)meta.append(value)
90
+ button.append(meta)
91
+ cleanups.push(()=>{if(name)control.parentElement.prepend(name);if(value)control.parentElement.append(value)})
92
+ }
93
+ if(isSelect && Array.from(control.options).some(o=>o.dataset.description)){
94
+ const describe=()=>{if(!restoringFocus && active?.button!==button)showTip(button,control.selectedOptions[0]?.dataset.description)}
95
+ button.addEventListener('pointerenter',describe);button.addEventListener('focus',describe)
96
+ button.addEventListener('pointerleave',hideTip);button.addEventListener('blur',hideTip)
97
+ button.addEventListener('pointerdown',hideTip)
98
+ }
99
+ control.before(button)
100
+ control.setAttribute('data-prts-native-model','');control.tabIndex=-1
101
+ control.setAttribute('aria-hidden','true')
102
+ const sync=()=>{
103
+ button.disabled=control.disabled
104
+ const text=isSelect?(control.selectedOptions[0]?.textContent || ''):''
105
+ if(caption.textContent!==text){caption.textContent=text;if(tipTarget===button)showTip(button,control.selectedOptions[0]?.dataset.description)}
106
+ if(!isSelect){button.classList.add('prts-page-color');button.style.setProperty('--sample',control.value);button.title=control.value.toUpperCase()}
107
+ }
108
+ // Host updates use .value assignments, which do not emit change events.
109
+ const descriptor=Object.getOwnPropertyDescriptor(isSelect?window.HTMLSelectElement.prototype:window.HTMLInputElement.prototype,'value')
110
+ Object.defineProperty(control,'value',{configurable:true,get(){return descriptor.get.call(this)},set(value){descriptor.set.call(this,value);sync()}})
111
+ const observer=new window.MutationObserver(()=>{sync();if(control.disabled && active?.button===button)close()})
112
+ observer.observe(control,{attributes:true,childList:true,subtree:true})
113
+ control.addEventListener('change',sync)
114
+ const commit=value=>{
115
+ control.value=value
116
+ control.dispatchEvent(new window.Event('input',{bubbles:true}))
117
+ control.dispatchEvent(new window.Event('change',{bubbles:true}))
118
+ }
119
+ const show=()=>{
120
+ if(active?.button===button){close(true);return}
121
+ const popup=open(button,isSelect?'listbox':'dialog')
122
+ if(isSelect){
123
+ const options=Array.from(control.options)
124
+ const choose=option=>{close(true);commit(option.value)}
125
+ for(const option of options){
126
+ const row=document.createElement('button');row.type='button';row.role='option'
127
+ row.textContent=option.textContent;row.setAttribute('aria-label',option.textContent);row.disabled=option.disabled
128
+ row.setAttribute('aria-selected',String(option.selected))
129
+ if(option.dataset.description){
130
+ const describe=()=>showTip(row,option.dataset.description,popup)
131
+ row.addEventListener('pointerenter',describe);row.addEventListener('focus',describe)
132
+ row.addEventListener('pointerleave',hideTip);row.addEventListener('blur',hideTip)
133
+ }
134
+ row.addEventListener('click',()=>choose(option));popup.append(row)
135
+ }
136
+ popup.addEventListener('keydown',e=>{
137
+ const rows=Array.from(popup.querySelectorAll('button:not(:disabled)'))
138
+ let index=rows.indexOf(document.activeElement)
139
+ if(['ArrowDown','ArrowUp','Home','End'].includes(e.key)){
140
+ e.preventDefault()
141
+ index=e.key==='Home'?0:e.key==='End'?rows.length-1:(index+(e.key==='ArrowDown'?1:-1)+rows.length)%rows.length
142
+ rows[index]?.focus({preventScroll:true})
143
+ }
144
+ })
145
+ present(popup,button)
146
+ popup.querySelector('[aria-selected="true"]:not(:disabled)')?.focus({preventScroll:true})
147
+ }else{
148
+ popup.classList.add('prts-page-color-editor')
149
+ popup.innerHTML='<strong>自定义颜色</strong><i class="prts-page-sample"></i><label>HEX<input aria-label="HEX 颜色" maxlength="7" spellcheck="false"></label>'+['R','G','B'].map((name,i)=>'<label>'+name+'<input type="range" min="0" max="255" aria-label="'+name+' 通道" data-channel="'+i+'"><output></output></label>').join('')+'<footer><button type="button" data-cancel>取消</button><button type="button" data-apply>应用颜色</button></footer>'
150
+ let draft=control.value
151
+ const hex=popup.querySelector('input:not([type])'),ranges=Array.from(popup.querySelectorAll('[type=range]'))
152
+ const render=()=>{
153
+ hex.value=draft.toUpperCase();hex.removeAttribute('aria-invalid')
154
+ popup.querySelector('[data-apply]').disabled=false
155
+ popup.querySelector('i').style.background=draft
156
+ ranges.forEach((range,i)=>{range.value=parseInt(draft.slice(1+i*2,3+i*2),16);range.nextElementSibling.textContent=range.value})
157
+ }
158
+ hex.addEventListener('input',()=>{
159
+ const valid=/^#?[0-9a-f]{6}$/i.test(hex.value)
160
+ hex.setAttribute('aria-invalid',String(!valid));popup.querySelector('[data-apply]').disabled=!valid
161
+ if(valid){draft='#'+hex.value.replace('#','');render()}
162
+ })
163
+ ranges.forEach(range=>range.addEventListener('input',()=>{draft='#'+ranges.map(r=>Number(r.value).toString(16).padStart(2,'0')).join('');render()}))
164
+ popup.querySelector('[data-cancel]').onclick=()=>close(true)
165
+ popup.querySelector('[data-apply]').onclick=()=>{close(true);commit(draft)}
166
+ render();present(popup,button);hex.focus({preventScroll:true})
167
+ }
168
+ }
169
+ button.addEventListener('click',e=>{e.preventDefault();show()})
170
+ button.addEventListener('keydown',e=>{
171
+ if(isSelect && ['ArrowDown','ArrowUp','Home','End'].includes(e.key)){e.preventDefault();e.stopPropagation();if(active?.button!==button)show()}
172
+ })
173
+ sync()
174
+ cleanups.push(()=>{observer.disconnect();control.removeEventListener('change',sync);delete control.value;button.remove();control.removeAttribute('data-prts-native-model');control.removeAttribute('aria-hidden');control.removeAttribute('tabindex')})
175
+ }
176
+ const outside=e=>{if(active&&!active.popup.contains(e.target)&&!active.button.contains(e.target))close()}
177
+ const scroll=e=>{if(active && (e.target===document || e.target.contains?.(active.button)) && !active.popup.contains(e.target))close()}
178
+ document.addEventListener('pointerdown',outside,true)
179
+ document.addEventListener('focusin',outside,true)
180
+ const dismiss=()=>close()
181
+ window.addEventListener('resize',dismiss)
182
+ document.addEventListener('scroll',scroll,true)
183
+ panel.addEventListener('close',dismiss,true)
184
+ return {dispose(){close();cleanups.forEach(fn=>fn());document.removeEventListener('pointerdown',outside,true);document.removeEventListener('focusin',outside,true);window.removeEventListener('resize',dismiss);document.removeEventListener('scroll',scroll,true);panel.removeEventListener('close',dismiss,true)}}
185
+ }
@@ -241,7 +241,7 @@ export function normalizePreferences(value) {
241
241
  cornerColor: colorOr(input.cornerColor, DEFAULT_PREFERENCES.cornerColor),
242
242
  cornerColorLight: colorOr(input.cornerColorLight, colorOr(input.cornerColor, DEFAULT_PREFERENCES.cornerColor)),
243
243
  cornerCustomColorLight: colorOr(input.cornerCustomColorLight, colorOr(input.cornerCustomColor, DEFAULT_PREFERENCES.cornerCustomColor)),
244
- cursor: input.cursor === 'system' ? 'system' : DEFAULT_PREFERENCES.cursor,
244
+ cursor: ['system', 'prts-follower'].includes(input.cursor) ? input.cursor : DEFAULT_PREFERENCES.cursor,
245
245
  conversationParticleDensity: enumOr(legacyDensities.conversation, PARTICLE_DENSITIES, particlePreset.conversationParticleDensity),
246
246
  heroParticleDensity: enumOr(legacyDensities.hero, PARTICLE_DENSITIES, particlePreset.heroParticleDensity),
247
247
  particleTraversalSpeed: particleTraversalSpeedOr(
@@ -1,4 +1,4 @@
1
- import { createCursorAdapter } from './cursor-adapter.js'
1
+ import { createCursorAdapter as createFollowerCursorAdapter } from './cursor-follower.js'
2
2
  import { normalizePreferences } from './preferences.js'
3
3
 
4
4
  export const SCHEME_COMMIT_PROGRESS = .15
@@ -80,7 +80,7 @@ export function createThemeController({ document, window, cssText, service, curs
80
80
  return { apply() {}, sync() {}, refresh() {}, setTheme() {}, beginThemeTransition() { return gesture }, toggle() {}, dispose() {} }
81
81
  }
82
82
 
83
- const cursor = createCursorAdapter({ document, window, assets: cursorAssets })
83
+ const cursor = createFollowerCursorAdapter({ document, window, assets: cursorAssets })
84
84
  const root = document.documentElement
85
85
  let style
86
86
  let disposed = false
@@ -761,7 +761,7 @@ export function createThemeController({ document, window, cssText, service, curs
761
761
  root.dataset.prtsTexture = preferences.texture
762
762
  root.dataset.prtsGlass = preferences.glass
763
763
  root.dataset.prtsMotion = preferences.motion
764
- root.dataset.prtsCursor = preferences.cursor
764
+ root.dataset.prtsCursor = preferences.cursor === 'prts-follower' ? 'prts' : preferences.cursor
765
765
  root.dataset.prtsParticlePattern = 'orthogonal'
766
766
  root.dataset.prtsSessionFlow = preferences.sessionFlow ? 'on' : 'off'
767
767
  root.dataset.prtsSessionGlow = preferences.sessionGlow ? 'on' : 'off'