2026-04-01 12:31:56 -07:00
( function ( ) {
"use strict" ;
const tarotCardImages = window . TarotCardImages || { } ;
const MONTH _LENGTHS = [ 31 , 28 , 31 , 30 , 31 , 30 , 31 , 31 , 30 , 31 , 30 , 31 ] ;
const MONTH _ABBR = [ "Jan" , "Feb" , "Mar" , "Apr" , "May" , "Jun" , "Jul" , "Aug" , "Sep" , "Oct" , "Nov" , "Dec" ] ;
const MINOR _RANKS = new Set ( [ "Two" , "Three" , "Four" , "Five" , "Six" , "Seven" , "Eight" , "Nine" , "Ten" ] ) ;
const COURT _RANKS = new Set ( [ "Knight" , "Queen" , "Prince" ] ) ;
const EXTRA _SUIT _ORDER = [ "wands" , "cups" , "swords" , "disks" ] ;
2026-04-01 16:08:52 -07:00
const HOUSE _MINOR _NUMBER _BANDS = [
[ 2 , 3 , 4 ] ,
[ 5 , 6 , 7 ] ,
[ 8 , 9 , 10 ] ,
[ 2 , 3 , 4 ] ,
[ 5 , 6 , 7 ] ,
[ 8 , 9 , 10 ]
] ;
const HOUSE _LEFT _SUITS = [ "Wands" , "Disks" , "Swords" , "Cups" , "Wands" , "Disks" ] ;
const HOUSE _RIGHT _SUITS = [ "Swords" , "Cups" , "Wands" , "Disks" , "Swords" , "Cups" ] ;
const HOUSE _MIDDLE _SUITS = [ "Wands" , "Cups" , "Swords" , "Disks" ] ;
const HOUSE _MIDDLE _RANKS = [ "Ace" , "Knight" , "Queen" , "Prince" , "Princess" ] ;
const HOUSE _TRUMP _ROWS = [
[ 0 ] ,
[ 20 , 21 , 12 ] ,
[ 19 , 10 , 2 , 1 , 3 , 16 ] ,
[ 18 , 17 , 15 , 14 , 13 , 9 , 8 , 7 , 6 , 5 , 4 ] ,
[ 11 ]
] ;
2026-04-01 19:26:38 -07:00
const HOUSE _TRUMP _GRID _ROWS = [ 1 , 2 , 3 , 4 , 5 ] ;
const HOUSE _BOTTOM _START _ROW = 8 ;
2026-04-01 16:08:52 -07:00
const HOUSE _LEFT _START _COLUMN = 2 ;
2026-04-01 19:26:38 -07:00
const HOUSE _MIDDLE _START _COLUMN = 6 ;
const HOUSE _RIGHT _START _COLUMN = 11 ;
const TAROT _CARD _WIDTH _RATIO = 2 ;
const TAROT _CARD _HEIGHT _RATIO = 3 ;
2026-04-01 12:31:56 -07:00
const ZODIAC _START _TOKEN _BY _SIGN _ID = {
aries : "03-21" ,
taurus : "04-20" ,
gemini : "05-21" ,
cancer : "06-21" ,
leo : "07-23" ,
virgo : "08-23" ,
libra : "09-23" ,
scorpio : "10-23" ,
sagittarius : "11-22" ,
capricorn : "12-22" ,
aquarius : "01-20" ,
pisces : "02-19"
} ;
2026-04-01 19:26:38 -07:00
const MASTER _GRID _SIZE = 14 ;
2026-04-04 03:39:29 -07:00
const FRAME _GRID _ZOOM _STEPS = [ 1 , 1.2 , 1.4 , 1.7 , 2 , 2.4 , 3 , 3.6 , 4.2 ] ;
const FRAME _GRID _MIN _SCALE = 0.8 ;
const FRAME _GRID _MAX _SCALE = 5.2 ;
const FRAME _CUSTOM _LAYOUTS _STORAGE _KEY = "tarot-frame-custom-layouts-v1" ;
const FRAME _ACTIVE _LAYOUT _STORAGE _KEY = "tarot-frame-active-layout-v1" ;
const FRAME _CARD _PICKER _QUERY _STORAGE _KEY = "tarot-frame-card-picker-query-v1" ;
const FRAME _LAYOUT _NOTES _STORAGE _KEY = "tarot-frame-layout-notes-v1" ;
const HOUSE _TOP _INFO _MODE _IDS = [ "hebrew" , "planet" , "zodiac" , "trump" , "path" , "date" ] ;
const HOUSE _BOTTOM _INFO _MODE _IDS = [ "zodiac" , "decan" , "month" , "ruler" , "date" ] ;
const FRAME _LONG _PRESS _DELAY _MS = 460 ;
const FRAME _LONG _PRESS _MOVE _TOLERANCE = 10 ;
2026-04-04 12:15:52 -07:00
const FRAME _TOUCH _DRAG _ACTIVATION _DELAY _MS = 140 ;
2026-04-01 19:26:38 -07:00
const EXPORT _SLOT _WIDTH = 120 ;
const EXPORT _SLOT _HEIGHT = Math . round ( ( EXPORT _SLOT _WIDTH * TAROT _CARD _HEIGHT _RATIO ) / TAROT _CARD _WIDTH _RATIO ) ;
2026-04-01 12:31:56 -07:00
const EXPORT _CARD _INSET = 0 ;
const EXPORT _GRID _GAP = 10 ;
const EXPORT _PADDING = 28 ;
const EXPORT _BACKGROUND = "#0f0f17" ;
const EXPORT _PANEL = "#18181b" ;
const EXPORT _CARD _BORDER = "#475569" ;
const EXPORT _BADGE _BACKGROUND = "rgba(2, 6, 23, 0.9)" ;
const EXPORT _BADGE _TEXT = "#f8fafc" ;
const EXPORT _FORMATS = {
webp : {
mimeType : "image/webp" ,
extension : "webp" ,
quality : 0.98
}
} ;
2026-04-01 16:08:52 -07:00
const FRAME _LAYOUT _GROUPS = [
2026-04-01 12:31:56 -07:00
{
id : "extra-cards" ,
2026-04-01 19:26:38 -07:00
title : "Extra Band" ,
description : "Two-row top band for aces, princesses, and the non-zodiac majors." ,
positions : [
... Array . from ( { length : MASTER _GRID _SIZE } , ( _ , index ) => ( { row : 1 , column : index + 1 } ) ) ,
{ row : 2 , column : 6 } ,
{ row : 2 , column : 7 } ,
{ row : 2 , column : 8 } ,
{ row : 2 , column : 9 }
] ,
2026-04-01 12:31:56 -07:00
getOrderedCards ( cards ) {
return cards
. filter ( ( card ) => isExtraTopRowCard ( card ) )
. sort ( compareExtraTopRowCards ) ;
}
} ,
{
id : "small-cards" ,
title : "Small Cards" ,
description : "Outer perimeter in chronological decan order." ,
2026-04-01 19:26:38 -07:00
positions : buildPerimeterPath ( 10 , 5 , 3 ) ,
2026-04-01 12:31:56 -07:00
getOrderedCards ( cards ) {
return cards
. filter ( ( card ) => isSmallCard ( card ) )
. sort ( ( left , right ) => compareDateTokens ( getRelation ( left , "decan" ) ? . data ? . dateStart , getRelation ( right , "decan" ) ? . data ? . dateStart , "03-21" ) ) ;
}
} ,
{
id : "court-dates" ,
title : "Court Dates" ,
description : "Inner left frame in chronological court-date order." ,
2026-04-01 19:26:38 -07:00
positions : buildPerimeterPath ( 4 , 8 , 4 ) ,
2026-04-01 12:31:56 -07:00
getOrderedCards ( cards ) {
return cards
. filter ( ( card ) => isCourtDateCard ( card ) )
. sort ( ( left , right ) => compareDateTokens ( getRelation ( left , "courtDateWindow" ) ? . data ? . dateStart , getRelation ( right , "courtDateWindow" ) ? . data ? . dateStart , "11-12" ) ) ;
}
} ,
{
id : "zodiac-trumps" ,
title : "Zodiac Trumps" ,
description : "Inner right frame in chronological zodiac order." ,
2026-04-01 19:26:38 -07:00
positions : buildPerimeterPath ( 4 , 8 , 8 ) ,
2026-04-01 12:31:56 -07:00
getOrderedCards ( cards ) {
return cards
. filter ( ( card ) => isZodiacTrump ( card ) )
. sort ( ( left , right ) => {
const leftSignId = normalizeKey ( getRelation ( left , "zodiacCorrespondence" ) ? . data ? . signId ) ;
const rightSignId = normalizeKey ( getRelation ( right , "zodiacCorrespondence" ) ? . data ? . signId ) ;
return compareDateTokens ( ZODIAC _START _TOKEN _BY _SIGN _ID [ leftSignId ] , ZODIAC _START _TOKEN _BY _SIGN _ID [ rightSignId ] , "03-21" ) ;
} ) ;
}
}
] ;
2026-04-01 16:08:52 -07:00
const LAYOUT _PRESETS = [
{
id : "frames" ,
label : "Frames" ,
2026-04-01 19:26:38 -07:00
title : "Master 14x14 Frame Grid" ,
subtitle : "A two-row top band holds the remaining 18 cards, while the centered frame keeps the small cards, court dates, and zodiac trumps grouped together. Every square on the grid is a snap target for custom layouts." ,
2026-04-01 16:08:52 -07:00
statusMessage : "Frames layout applied to the master grid." ,
legendItems : FRAME _LAYOUT _GROUPS . map ( ( group ) => ( {
title : group . title ,
description : group . description
} ) ) ,
buildPlacements ( cards ) {
const placements = [ ] ;
FRAME _LAYOUT _GROUPS . forEach ( ( group ) => {
assignCardsToPositions ( placements , group . positions , group . getOrderedCards ( cards ) ) ;
} ) ;
return placements ;
}
} ,
{
id : "house" ,
label : "House of Cards" ,
title : "House of Cards Layout" ,
2026-04-01 19:26:38 -07:00
subtitle : "The legacy house composition now lives inside the same draggable 14x14 grid. Centered trump tiers sit above the three lower columns, while every square still remains available for custom rearranging." ,
2026-04-01 16:08:52 -07:00
statusMessage : "House of Cards layout applied to the master grid." ,
legendItems : [
{
title : "Trump Tiers" ,
description : "Five centered major-arcana rows preserve the original House silhouette."
} ,
{
title : "Left Wing" ,
description : "Minor bands descend through Wands, Disks, Swords, Cups, then repeat Wands and Disks."
} ,
{
title : "Middle Court" ,
description : "Aces and the court ranks run through the four suits down the center column."
} ,
{
title : "Right Wing" ,
description : "Minor bands mirror the opposite side with Swords, Cups, Wands, Disks, then Swords and Cups."
}
] ,
buildPlacements ( cards ) {
return buildHousePlacements ( cards ) ;
}
}
] ;
2026-04-01 12:31:56 -07:00
const state = {
initialized : false ,
layoutReady : false ,
cardSignature : "" ,
slotAssignments : new Map ( ) ,
statusMessage : "Loading tarot cards..." ,
drag : null ,
2026-04-04 03:39:29 -07:00
panMode : false ,
panGesture : null ,
pinchGesture : null ,
longPress : null ,
cardPicker : {
open : false ,
slotId : "" ,
query : ""
} ,
2026-04-01 12:31:56 -07:00
suppressClick : false ,
showInfo : true ,
settingsOpen : false ,
2026-04-01 16:08:52 -07:00
layoutMenuOpen : false ,
2026-04-04 03:39:29 -07:00
gridFocusMode : false ,
2026-04-01 16:08:52 -07:00
currentLayoutId : "frames" ,
2026-04-04 03:39:29 -07:00
customLayouts : [ ] ,
layoutNotesById : { } ,
2026-04-01 12:31:56 -07:00
exportInProgress : false ,
2026-04-01 19:26:38 -07:00
exportFormat : "webp" ,
2026-04-04 03:39:29 -07:00
gridZoomStepIndex : 0 ,
gridZoomScale : FRAME _GRID _ZOOM _STEPS [ 0 ]
2026-04-01 12:31:56 -07:00
} ;
let config = {
ensureTarotSection : null ,
2026-04-01 16:08:52 -07:00
getCards : ( ) => [ ] ,
2026-04-02 22:06:19 -07:00
openCardLightbox : ( ) => { } ,
2026-04-01 16:08:52 -07:00
getHouseTopCardsVisible : ( ) => true ,
getHouseTopInfoModes : ( ) => ( { } ) ,
getHouseBottomCardsVisible : ( ) => true ,
getHouseBottomInfoModes : ( ) => ( { } ) ,
setHouseTopCardsVisible : ( ) => { } ,
setHouseTopInfoMode : ( ) => { } ,
setHouseBottomCardsVisible : ( ) => { } ,
setHouseBottomInfoMode : ( ) => { }
2026-04-01 12:31:56 -07:00
} ;
2026-04-04 03:39:29 -07:00
let cardPickerEl = null ;
let cardPickerTitleEl = null ;
let cardPickerSearchEl = null ;
let cardPickerSectionsEl = null ;
let pendingGridViewportRestoreFrameId = 0 ;
let activeTouchGestureCapture = false ;
2026-04-01 12:31:56 -07:00
function buildPerimeterPath ( size , rowOffset = 1 , columnOffset = 1 ) {
const path = [ ] ;
for ( let column = 0 ; column < size ; column += 1 ) {
path . push ( { row : rowOffset , column : columnOffset + column } ) ;
}
for ( let row = 1 ; row < size - 1 ; row += 1 ) {
path . push ( { row : rowOffset + row , column : columnOffset + size - 1 } ) ;
}
for ( let column = size - 1 ; column >= 0 ; column -= 1 ) {
path . push ( { row : rowOffset + size - 1 , column : columnOffset + column } ) ;
}
for ( let row = size - 2 ; row >= 1 ; row -= 1 ) {
path . push ( { row : rowOffset + row , column : columnOffset } ) ;
}
return path ;
}
function getElements ( ) {
return {
2026-04-04 03:39:29 -07:00
tarotFrameSectionEl : document . getElementById ( "tarot-frame-section" ) ,
tarotFrameViewEl : document . getElementById ( "tarot-frame-view" ) ,
2026-04-01 12:31:56 -07:00
tarotFrameBoardEl : document . getElementById ( "tarot-frame-board" ) ,
tarotFrameStatusEl : document . getElementById ( "tarot-frame-status" ) ,
2026-04-04 03:39:29 -07:00
tarotFrameOverviewEl : document . getElementById ( "tarot-frame-overview" ) ,
tarotFramePanToggleEl : document . getElementById ( "tarot-frame-pan-toggle" ) ,
tarotFrameFocusToggleEl : document . getElementById ( "tarot-frame-focus-toggle" ) ,
tarotFrameFocusExitEl : document . getElementById ( "tarot-frame-focus-exit" ) ,
2026-04-01 16:08:52 -07:00
tarotFrameLayoutToggleEl : document . getElementById ( "tarot-frame-layout-toggle" ) ,
tarotFrameLayoutPanelEl : document . getElementById ( "tarot-frame-layout-panel" ) ,
2026-04-01 12:31:56 -07:00
tarotFrameSettingsToggleEl : document . getElementById ( "tarot-frame-settings-toggle" ) ,
tarotFrameSettingsPanelEl : document . getElementById ( "tarot-frame-settings-panel" ) ,
2026-04-01 19:26:38 -07:00
tarotFrameGridZoomEl : document . getElementById ( "tarot-frame-grid-zoom" ) ,
2026-04-01 12:31:56 -07:00
tarotFrameShowInfoEl : document . getElementById ( "tarot-frame-show-info" ) ,
2026-04-01 16:08:52 -07:00
tarotFrameHouseSettingsEl : document . getElementById ( "tarot-frame-house-settings" ) ,
tarotFrameHouseTopCardsVisibleEl : document . getElementById ( "tarot-frame-house-top-cards-visible" ) ,
tarotFrameHouseTopInfoHebrewEl : document . getElementById ( "tarot-frame-house-top-info-hebrew" ) ,
tarotFrameHouseTopInfoPlanetEl : document . getElementById ( "tarot-frame-house-top-info-planet" ) ,
tarotFrameHouseTopInfoZodiacEl : document . getElementById ( "tarot-frame-house-top-info-zodiac" ) ,
tarotFrameHouseTopInfoTrumpEl : document . getElementById ( "tarot-frame-house-top-info-trump" ) ,
tarotFrameHouseTopInfoPathEl : document . getElementById ( "tarot-frame-house-top-info-path" ) ,
tarotFrameHouseTopInfoDateEl : document . getElementById ( "tarot-frame-house-top-info-date" ) ,
tarotFrameHouseBottomCardsVisibleEl : document . getElementById ( "tarot-frame-house-bottom-cards-visible" ) ,
tarotFrameHouseBottomInfoZodiacEl : document . getElementById ( "tarot-frame-house-bottom-info-zodiac" ) ,
tarotFrameHouseBottomInfoDecanEl : document . getElementById ( "tarot-frame-house-bottom-info-decan" ) ,
tarotFrameHouseBottomInfoMonthEl : document . getElementById ( "tarot-frame-house-bottom-info-month" ) ,
tarotFrameHouseBottomInfoRulerEl : document . getElementById ( "tarot-frame-house-bottom-info-ruler" ) ,
tarotFrameHouseBottomInfoDateEl : document . getElementById ( "tarot-frame-house-bottom-info-date" ) ,
2026-04-04 03:39:29 -07:00
tarotFrameClearGridEl : document . getElementById ( "tarot-frame-clear-grid" ) ,
2026-04-01 12:31:56 -07:00
tarotFrameExportWebpEl : document . getElementById ( "tarot-frame-export-webp" )
} ;
}
2026-04-04 03:39:29 -07:00
function normalizeLabelText ( value ) {
return String ( value || "" ) . replace ( /\s+/g , " " ) . trim ( ) ;
}
function isSmallCard ( card ) {
return card ? . arcana === "Minor"
&& MINOR _RANKS . has ( String ( card ? . rank || "" ) )
&& Boolean ( getRelation ( card , "decan" ) ) ;
}
function isCourtDateCard ( card ) {
return COURT _RANKS . has ( String ( card ? . rank || "" ) )
&& Boolean ( getRelation ( card , "courtDateWindow" ) ) ;
}
function isZodiacTrump ( card ) {
return card ? . arcana === "Major"
&& Boolean ( getRelation ( card , "zodiacCorrespondence" ) ) ;
}
function getExtraTopRowCategory ( card ) {
const rank = String ( card ? . rank || "" ) . trim ( ) ;
if ( rank === "Ace" ) {
return 0 ;
}
if ( card ? . arcana === "Major" ) {
return 1 ;
}
if ( rank === "Princess" ) {
return 2 ;
}
return 3 ;
}
function compareSuitOrder ( leftSuit , rightSuit ) {
const leftIndex = EXTRA _SUIT _ORDER . indexOf ( normalizeKey ( leftSuit ) ) ;
const rightIndex = EXTRA _SUIT _ORDER . indexOf ( normalizeKey ( rightSuit ) ) ;
const safeLeft = leftIndex === - 1 ? EXTRA _SUIT _ORDER . length : leftIndex ;
const safeRight = rightIndex === - 1 ? EXTRA _SUIT _ORDER . length : rightIndex ;
return safeLeft - safeRight ;
}
function compareExtraTopRowCards ( left , right ) {
const categoryDiff = getExtraTopRowCategory ( left ) - getExtraTopRowCategory ( right ) ;
if ( categoryDiff !== 0 ) {
return categoryDiff ;
}
const category = getExtraTopRowCategory ( left ) ;
if ( category === 0 || category === 2 ) {
return compareSuitOrder ( left ? . suit , right ? . suit ) ;
}
if ( category === 1 ) {
return Number ( left ? . number ) - Number ( right ? . number ) ;
}
return String ( left ? . name || "" ) . localeCompare ( String ( right ? . name || "" ) ) ;
}
function isExtraTopRowCard ( card ) {
return Boolean ( card ) && ! isSmallCard ( card ) && ! isCourtDateCard ( card ) && ! isZodiacTrump ( card ) ;
}
function buildReadyStatus ( cards ) {
return ` ${ Array . isArray ( cards ) ? cards . length : 0 } cards ready. Drag cards freely and use Settings to change the grid zoom for any layout. ` ;
}
function clampFrameGridZoomScale ( value ) {
const numericValue = Number ( value ) ;
if ( ! Number . isFinite ( numericValue ) ) {
return FRAME _GRID _ZOOM _STEPS [ 0 ] ;
}
return Math . min ( FRAME _GRID _MAX _SCALE , Math . max ( FRAME _GRID _MIN _SCALE , numericValue ) ) ;
}
function getNearestFrameZoomStepIndex ( scale ) {
const safeScale = clampFrameGridZoomScale ( scale ) ;
let bestIndex = 0 ;
let bestDistance = Number . POSITIVE _INFINITY ;
FRAME _GRID _ZOOM _STEPS . forEach ( ( step , index ) => {
const distance = Math . abs ( step - safeScale ) ;
if ( distance < bestDistance ) {
bestDistance = distance ;
bestIndex = index ;
}
} ) ;
return bestIndex ;
}
function getGridZoomScale ( ) {
return clampFrameGridZoomScale ( state . gridZoomScale ) ;
}
function buildPanelCountText ( cards = getCards ( ) ) {
return ` ${ cards . length } cards / ${ MASTER _GRID _SIZE * MASTER _GRID _SIZE } cells · Zoom ${ Math . round ( getGridZoomScale ( ) * 100 ) } % ` ;
}
function normalizeKey ( value ) {
return String ( value || "" ) . trim ( ) . toLowerCase ( ) ;
}
function readStorageValue ( key ) {
try {
return String ( window . localStorage ? . getItem ? . ( key ) || "" ) ;
} catch ( _error ) {
return "" ;
}
}
function writeStorageValue ( key , value ) {
try {
window . localStorage ? . setItem ? . ( key , value ) ;
return true ;
} catch ( _error ) {
return false ;
}
}
function removeStorageValue ( key ) {
try {
window . localStorage ? . removeItem ? . ( key ) ;
return true ;
} catch ( _error ) {
return false ;
}
}
function normalizeLayoutLabel ( value ) {
return String ( value || "" )
. replace ( /\s+/g , " " )
. trim ( )
. slice ( 0 , 64 ) ;
}
function normalizeLayoutNote ( value ) {
return String ( value || "" )
. replace ( /\r\n?/g , "\n" )
. replace ( /\u0000/g , "" )
. trim ( )
. slice ( 0 , 1600 ) ;
}
function createSavedLayoutId ( ) {
return ` saved- ${ Date . now ( ) . toString ( 36 ) } - ${ Math . random ( ) . toString ( 36 ) . slice ( 2 , 8 ) } ` ;
}
function isValidSlotId ( value ) {
const match = String ( value || "" ) . trim ( ) . match ( /^(\d+):(\d+)$/ ) ;
if ( ! match ) {
return false ;
}
const row = Number ( match [ 1 ] ) ;
const column = Number ( match [ 2 ] ) ;
return Number . isInteger ( row )
&& Number . isInteger ( column )
&& row >= 1
&& row <= MASTER _GRID _SIZE
&& column >= 1
&& column <= MASTER _GRID _SIZE ;
}
function buildFrameSettingsSnapshot ( ) {
const topModes = config . getHouseTopInfoModes ? . ( ) || { } ;
const bottomModes = config . getHouseBottomInfoModes ? . ( ) || { } ;
return {
showInfo : Boolean ( state . showInfo ) ,
gridZoomScale : getGridZoomScale ( ) ,
gridZoomStepIndex : state . gridZoomStepIndex ,
houseTopCardsVisible : config . getHouseTopCardsVisible ? . ( ) !== false ,
houseBottomCardsVisible : config . getHouseBottomCardsVisible ? . ( ) !== false ,
houseTopInfoModes : HOUSE _TOP _INFO _MODE _IDS . reduce ( ( result , mode ) => {
result [ mode ] = Boolean ( topModes [ mode ] ) ;
return result ;
} , { } ) ,
houseBottomInfoModes : HOUSE _BOTTOM _INFO _MODE _IDS . reduce ( ( result , mode ) => {
result [ mode ] = Boolean ( bottomModes [ mode ] ) ;
return result ;
} , { } )
} ;
}
function normalizeFrameSettingsSnapshot ( rawSettings ) {
const fallback = buildFrameSettingsSnapshot ( ) ;
const raw = rawSettings && typeof rawSettings === "object" ? rawSettings : { } ;
const rawZoomIndex = Number ( raw . gridZoomStepIndex ) ;
const rawZoomScale = Number ( raw . gridZoomScale ) ;
const normalizedZoomScale = Number . isFinite ( rawZoomScale )
? clampFrameGridZoomScale ( rawZoomScale )
: ( Number . isFinite ( rawZoomIndex )
? clampFrameGridZoomScale ( FRAME _GRID _ZOOM _STEPS [ Math . max ( 0 , Math . min ( FRAME _GRID _ZOOM _STEPS . length - 1 , rawZoomIndex ) ) ] )
: fallback . gridZoomScale ) ;
return {
showInfo : raw . showInfo === undefined ? fallback . showInfo : Boolean ( raw . showInfo ) ,
gridZoomScale : normalizedZoomScale ,
gridZoomStepIndex : Number . isFinite ( rawZoomIndex )
? Math . max ( 0 , Math . min ( FRAME _GRID _ZOOM _STEPS . length - 1 , rawZoomIndex ) )
: getNearestFrameZoomStepIndex ( normalizedZoomScale ) ,
houseTopCardsVisible : raw . houseTopCardsVisible === undefined ? fallback . houseTopCardsVisible : Boolean ( raw . houseTopCardsVisible ) ,
houseBottomCardsVisible : raw . houseBottomCardsVisible === undefined ? fallback . houseBottomCardsVisible : Boolean ( raw . houseBottomCardsVisible ) ,
houseTopInfoModes : HOUSE _TOP _INFO _MODE _IDS . reduce ( ( result , mode ) => {
result [ mode ] = raw . houseTopInfoModes ? . [ mode ] === undefined
? fallback . houseTopInfoModes [ mode ]
: Boolean ( raw . houseTopInfoModes [ mode ] ) ;
return result ;
} , { } ) ,
houseBottomInfoModes : HOUSE _BOTTOM _INFO _MODE _IDS . reduce ( ( result , mode ) => {
result [ mode ] = raw . houseBottomInfoModes ? . [ mode ] === undefined
? fallback . houseBottomInfoModes [ mode ]
: Boolean ( raw . houseBottomInfoModes [ mode ] ) ;
return result ;
} , { } )
} ;
}
function captureSlotAssignmentsSnapshot ( cards = getCards ( ) ) {
const validCardIds = new Set ( cards . map ( ( card ) => getCardId ( card ) ) . filter ( Boolean ) ) ;
return [ ... state . slotAssignments . entries ( ) ]
. map ( ( [ slotId , cardId ] ) => ( {
slotId : String ( slotId || "" ) . trim ( ) ,
cardId : String ( cardId || "" ) . trim ( )
} ) )
. filter ( ( entry ) => isValidSlotId ( entry . slotId ) && validCardIds . has ( entry . cardId ) )
. sort ( ( left , right ) => {
const [ leftRow , leftColumn ] = left . slotId . split ( ":" ) . map ( Number ) ;
const [ rightRow , rightColumn ] = right . slotId . split ( ":" ) . map ( Number ) ;
if ( leftRow !== rightRow ) {
return leftRow - rightRow ;
}
return leftColumn - rightColumn ;
} ) ;
}
function normalizeSavedLayoutRecord ( rawLayout ) {
const label = normalizeLayoutLabel ( rawLayout ? . label || rawLayout ? . name ) ;
if ( ! label ) {
return null ;
}
const id = String ( rawLayout ? . id || "" ) . trim ( ) || createSavedLayoutId ( ) ;
const slotAssignments = Array . isArray ( rawLayout ? . slotAssignments )
? rawLayout . slotAssignments
. map ( ( entry ) => ( {
slotId : String ( entry ? . slotId || "" ) . trim ( ) ,
cardId : String ( entry ? . cardId || "" ) . trim ( )
} ) )
. filter ( ( entry ) => isValidSlotId ( entry . slotId ) && entry . cardId )
: [ ] ;
const settings = normalizeFrameSettingsSnapshot ( rawLayout ? . settings ) ;
const createdAt = String ( rawLayout ? . createdAt || rawLayout ? . updatedAt || "" ) . trim ( ) ;
const note = normalizeLayoutNote ( rawLayout ? . note ) ;
return {
id ,
label ,
title : label ,
subtitle : "Saved browser layout with custom card positions, frame display settings, and optional notes." ,
statusMessage : ` ${ label } layout loaded from browser storage. ` ,
legendItems : [
{
title : "Saved Layout" ,
description : "Custom card positions, frame settings, and notes stored only in this browser."
}
] ,
slotAssignments ,
settings ,
note ,
createdAt ,
isCustom : true
} ;
}
function loadLayoutNotesFromStorage ( ) {
const rawValue = readStorageValue ( FRAME _LAYOUT _NOTES _STORAGE _KEY ) ;
if ( ! rawValue ) {
state . layoutNotesById = { } ;
return ;
}
try {
const parsed = JSON . parse ( rawValue ) ;
if ( ! parsed || typeof parsed !== "object" || Array . isArray ( parsed ) ) {
state . layoutNotesById = { } ;
return ;
}
state . layoutNotesById = Object . entries ( parsed ) . reduce ( ( result , [ layoutId , note ] ) => {
const normalizedId = String ( layoutId || "" ) . trim ( ) ;
const normalizedNote = normalizeLayoutNote ( note ) ;
if ( normalizedId && normalizedNote ) {
result [ normalizedId ] = normalizedNote ;
}
return result ;
} , { } ) ;
} catch ( _error ) {
state . layoutNotesById = { } ;
}
}
function loadSavedLayoutsFromStorage ( ) {
const rawValue = readStorageValue ( FRAME _CUSTOM _LAYOUTS _STORAGE _KEY ) ;
if ( ! rawValue ) {
state . customLayouts = [ ] ;
return ;
}
try {
const parsed = JSON . parse ( rawValue ) ;
state . customLayouts = Array . isArray ( parsed )
? parsed . map ( ( entry ) => normalizeSavedLayoutRecord ( entry ) ) . filter ( Boolean )
: [ ] ;
} catch ( _error ) {
state . customLayouts = [ ] ;
}
}
function persistSavedLayouts ( ) {
return writeStorageValue ( FRAME _CUSTOM _LAYOUTS _STORAGE _KEY , JSON . stringify ( state . customLayouts . map ( ( layout ) => ( {
id : layout . id ,
label : layout . label ,
slotAssignments : layout . slotAssignments ,
settings : layout . settings ,
note : normalizeLayoutNote ( layout . note ) ,
createdAt : layout . createdAt || new Date ( ) . toISOString ( )
} ) ) ) ) ;
}
function persistLayoutNotes ( ) {
const entries = Object . entries ( state . layoutNotesById || { } ) . reduce ( ( result , [ layoutId , note ] ) => {
const normalizedId = String ( layoutId || "" ) . trim ( ) ;
const normalizedNote = normalizeLayoutNote ( note ) ;
if ( normalizedId && normalizedNote ) {
result [ normalizedId ] = normalizedNote ;
}
return result ;
} , { } ) ;
if ( ! Object . keys ( entries ) . length ) {
removeStorageValue ( FRAME _LAYOUT _NOTES _STORAGE _KEY ) ;
return true ;
}
return writeStorageValue ( FRAME _LAYOUT _NOTES _STORAGE _KEY , JSON . stringify ( entries ) ) ;
}
function persistActiveLayoutId ( layoutId = state . currentLayoutId ) {
const nextId = String ( layoutId || "" ) . trim ( ) ;
if ( ! nextId ) {
removeStorageValue ( FRAME _ACTIVE _LAYOUT _STORAGE _KEY ) ;
return ;
}
writeStorageValue ( FRAME _ACTIVE _LAYOUT _STORAGE _KEY , nextId ) ;
}
function restoreActiveLayoutId ( ) {
const storedId = String ( readStorageValue ( FRAME _ACTIVE _LAYOUT _STORAGE _KEY ) || "" ) . trim ( ) ;
if ( ! storedId ) {
return ;
}
const nextLayout = getLayoutDefinition ( storedId ) ;
state . currentLayoutId = nextLayout . id ;
}
function persistCardPickerQuery ( query = state . cardPicker . query ) {
writeStorageValue ( FRAME _CARD _PICKER _QUERY _STORAGE _KEY , String ( query || "" ) ) ;
}
function restoreCardPickerQuery ( ) {
state . cardPicker . query = String ( readStorageValue ( FRAME _CARD _PICKER _QUERY _STORAGE _KEY ) || "" ) ;
}
function getLayoutNote ( layoutId = state . currentLayoutId ) {
const nextLayoutId = String ( layoutId || "" ) . trim ( ) ;
if ( ! nextLayoutId ) {
return "" ;
}
const storedNote = normalizeLayoutNote ( state . layoutNotesById ? . [ nextLayoutId ] ) ;
if ( storedNote ) {
return storedNote ;
}
return normalizeLayoutNote ( getSavedLayout ( nextLayoutId ) ? . note ) ;
}
function setLayoutNote ( layoutId , note , options = { } ) {
const nextLayoutId = String ( layoutId || "" ) . trim ( ) ;
if ( ! nextLayoutId ) {
return ;
}
const normalizedNote = normalizeLayoutNote ( note ) ;
if ( normalizedNote ) {
state . layoutNotesById [ nextLayoutId ] = normalizedNote ;
} else {
delete state . layoutNotesById [ nextLayoutId ] ;
}
const savedLayout = getSavedLayout ( nextLayoutId ) ;
if ( savedLayout ) {
savedLayout . note = normalizedNote ;
persistSavedLayouts ( ) ;
}
persistLayoutNotes ( ) ;
if ( options . updateUi !== false ) {
updateLayoutNotesUi ( ) ;
}
}
function getLayoutNotePlaceholder ( layoutDefinition = getLayoutDefinition ( ) ) {
if ( layoutDefinition ? . id === "house" ) {
return "Add placement notes, reading rules, or reminders for this House of Cards arrangement." ;
}
return "Add your own notes for this layout: intentions, spread logic, card placement rules, or reminders." ;
}
function updateLayoutNotesUi ( ) {
const { tarotFrameOverviewEl } = getElements ( ) ;
if ( ! ( tarotFrameOverviewEl instanceof HTMLElement ) ) {
return ;
}
const currentNote = getLayoutNote ( ) ;
const noteFieldEl = tarotFrameOverviewEl . querySelector ( "#tarot-frame-layout-note" ) ;
if ( noteFieldEl instanceof HTMLTextAreaElement ) {
noteFieldEl . value = currentNote ;
noteFieldEl . placeholder = getLayoutNotePlaceholder ( getLayoutDefinition ( ) ) ;
noteFieldEl . disabled = Boolean ( state . exportInProgress ) ;
}
const noteClearEl = tarotFrameOverviewEl . querySelector ( "[data-frame-note-clear='true']" ) ;
if ( noteClearEl instanceof HTMLButtonElement ) {
noteClearEl . disabled = ! currentNote || Boolean ( state . exportInProgress ) ;
}
const noteBadgeEl = tarotFrameOverviewEl . querySelector ( ".tarot-frame-notes-badge" ) ;
if ( noteBadgeEl instanceof HTMLElement ) {
noteBadgeEl . textContent = currentNote ? "Saved" : "Optional" ;
}
}
function applyGridFocusModeUi ( ) {
const {
tarotFrameSectionEl ,
tarotFrameFocusToggleEl ,
tarotFrameFocusExitEl
} = getElements ( ) ;
if ( tarotFrameSectionEl instanceof HTMLElement ) {
tarotFrameSectionEl . classList . toggle ( "is-grid-focus" , Boolean ( state . gridFocusMode ) ) ;
}
document . body . classList . toggle ( "is-tarot-frame-focus-lock" , Boolean ( state . gridFocusMode ) ) ;
if ( tarotFrameFocusToggleEl ) {
tarotFrameFocusToggleEl . setAttribute ( "aria-pressed" , state . gridFocusMode ? "true" : "false" ) ;
tarotFrameFocusToggleEl . classList . toggle ( "is-active" , Boolean ( state . gridFocusMode ) ) ;
tarotFrameFocusToggleEl . textContent = state . gridFocusMode ? "Exit Full Screen" : "Full Screen" ;
tarotFrameFocusToggleEl . disabled = Boolean ( state . exportInProgress ) ;
}
if ( tarotFrameFocusExitEl ) {
tarotFrameFocusExitEl . hidden = ! state . gridFocusMode ;
tarotFrameFocusExitEl . disabled = Boolean ( state . exportInProgress ) ;
}
}
function setGridFocusMode ( nextFocus ) {
const shouldFocus = Boolean ( nextFocus ) ;
if ( state . gridFocusMode === shouldFocus ) {
applyGridFocusModeUi ( ) ;
return ;
}
state . gridFocusMode = shouldFocus ;
state . settingsOpen = false ;
state . layoutMenuOpen = false ;
finishPanGesture ( ) ;
clearLongPressGesture ( ) ;
if ( state . cardPicker . open ) {
closeCardPicker ( ) ;
}
cleanupDrag ( ) ;
syncControls ( ) ;
updateViewportInteractionState ( ) ;
setStatus ( shouldFocus
? "Full-screen frame mode enabled. Tap outside the board or press Escape to exit."
: "Full-screen frame mode closed." ) ;
}
function applyFrameSettingsSnapshot ( rawSettings ) {
const settings = normalizeFrameSettingsSnapshot ( rawSettings ) ;
state . showInfo = settings . showInfo ;
state . gridZoomScale = settings . gridZoomScale ;
state . gridZoomStepIndex = settings . gridZoomStepIndex ;
config . setHouseTopCardsVisible ? . ( settings . houseTopCardsVisible ) ;
config . setHouseBottomCardsVisible ? . ( settings . houseBottomCardsVisible ) ;
HOUSE _TOP _INFO _MODE _IDS . forEach ( ( mode ) => {
config . setHouseTopInfoMode ? . ( mode , settings . houseTopInfoModes [ mode ] ) ;
} ) ;
HOUSE _BOTTOM _INFO _MODE _IDS . forEach ( ( mode ) => {
config . setHouseBottomInfoMode ? . ( mode , settings . houseBottomInfoModes [ mode ] ) ;
} ) ;
}
function getSuitSortIndex ( suit ) {
const suitIndex = EXTRA _SUIT _ORDER . indexOf ( normalizeKey ( suit ) ) ;
return suitIndex === - 1 ? EXTRA _SUIT _ORDER . length : suitIndex ;
}
function getMinorRankSortIndex ( rank ) {
const rankName = String ( rank || "" ) . trim ( ) ;
const lookup = {
Two : 2 ,
Three : 3 ,
Four : 4 ,
Five : 5 ,
Six : 6 ,
Seven : 7 ,
Eight : 8 ,
Nine : 9 ,
Ten : 10
} ;
return lookup [ rankName ] || 999 ;
}
function getCourtRankSortIndex ( rank ) {
const lookup = {
Knight : 0 ,
Queen : 1 ,
Prince : 2 ,
Princess : 3
} ;
return lookup [ String ( rank || "" ) . trim ( ) ] ? ? 999 ;
}
function getGridViewportElement ( ) {
const { tarotFrameBoardEl } = getElements ( ) ;
const viewportEl = tarotFrameBoardEl ? . querySelector ( ".tarot-frame-grid-viewport" ) ;
return viewportEl instanceof HTMLElement ? viewportEl : null ;
}
2026-04-04 12:15:52 -07:00
function isLayoutNoteTextarea ( element ) {
return element instanceof HTMLTextAreaElement && element . id === "tarot-frame-layout-note" ;
}
function blurLayoutNoteForBoardInteraction ( target ) {
const activeElement = document . activeElement ;
if ( ! isLayoutNoteTextarea ( activeElement ) ) {
return ;
}
if ( target instanceof Node && target === activeElement ) {
return ;
}
activeElement . blur ( ) ;
}
2026-04-04 03:39:29 -07:00
function updateViewportInteractionState ( ) {
const viewportEl = getGridViewportElement ( ) ;
if ( ! ( viewportEl instanceof HTMLElement ) ) {
return ;
}
viewportEl . classList . toggle ( "is-pan-enabled" , Boolean ( state . panMode ) ) ;
viewportEl . classList . toggle ( "is-panning" , Boolean ( state . panGesture ) ) ;
viewportEl . classList . toggle (
"is-touch-gesture-active" ,
Boolean ( state . pinchGesture || ( state . panGesture && state . panGesture . source === "touch" ) )
) ;
}
function syncActiveTouchGestureCapture ( ) {
const shouldCapture = Boolean ( state . pinchGesture || ( state . panGesture && state . panGesture . source === "touch" ) ) ;
if ( shouldCapture === activeTouchGestureCapture ) {
return ;
}
activeTouchGestureCapture = shouldCapture ;
const method = shouldCapture ? "addEventListener" : "removeEventListener" ;
document [ method ] ( "touchmove" , handleBoardTouchMove , { passive : false } ) ;
document [ method ] ( "touchend" , handleBoardTouchEnd , { passive : false } ) ;
document [ method ] ( "touchcancel" , handleBoardTouchCancel , { passive : false } ) ;
}
function createCardPickerElements ( ) {
if ( cardPickerEl ) {
return ;
}
cardPickerEl = document . createElement ( "div" ) ;
cardPickerEl . className = "tarot-frame-card-picker" ;
cardPickerEl . hidden = true ;
const headEl = document . createElement ( "div" ) ;
headEl . className = "tarot-frame-card-picker-head" ;
cardPickerTitleEl = document . createElement ( "div" ) ;
cardPickerTitleEl . className = "tarot-frame-card-picker-title" ;
cardPickerTitleEl . textContent = "Place Tarot Card" ;
const closeButtonEl = document . createElement ( "button" ) ;
closeButtonEl . type = "button" ;
closeButtonEl . className = "tarot-frame-card-picker-close" ;
closeButtonEl . textContent = "Close" ;
closeButtonEl . addEventListener ( "click" , ( ) => {
closeCardPicker ( ) ;
} ) ;
headEl . append ( cardPickerTitleEl , closeButtonEl ) ;
const searchWrapEl = document . createElement ( "label" ) ;
searchWrapEl . className = "tarot-frame-card-picker-search" ;
const searchLabelEl = document . createElement ( "span" ) ;
searchLabelEl . textContent = "Search Cards & Associations" ;
cardPickerSearchEl = document . createElement ( "input" ) ;
cardPickerSearchEl . type = "search" ;
cardPickerSearchEl . placeholder = "Find by card, planet, sign, decan..." ;
cardPickerSearchEl . autocomplete = "off" ;
cardPickerSearchEl . spellcheck = false ;
cardPickerSearchEl . addEventListener ( "input" , ( ) => {
state . cardPicker . query = String ( cardPickerSearchEl . value || "" ) ;
persistCardPickerQuery ( ) ;
renderCardPickerSections ( ) ;
} ) ;
searchWrapEl . append ( searchLabelEl , cardPickerSearchEl ) ;
cardPickerSectionsEl = document . createElement ( "div" ) ;
cardPickerSectionsEl . className = "tarot-frame-card-picker-sections" ;
cardPickerEl . append ( headEl , searchWrapEl , cardPickerSectionsEl ) ;
cardPickerEl . addEventListener ( "click" , ( event ) => {
event . stopPropagation ( ) ;
const target = event . target ;
if ( ! ( target instanceof Element ) ) {
return ;
}
const option = target . closest ( ".tarot-frame-card-picker-option[data-card-id]" ) ;
if ( ! ( option instanceof HTMLButtonElement ) ) {
return ;
}
placeCardInSlot ( state . cardPicker . slotId , option . dataset . cardId ) ;
closeCardPicker ( ) ;
} ) ;
document . body . appendChild ( cardPickerEl ) ;
}
function closeCardPicker ( ) {
state . cardPicker . open = false ;
state . cardPicker . slotId = "" ;
if ( cardPickerSearchEl ) {
cardPickerSearchEl . value = state . cardPicker . query ;
}
if ( cardPickerEl ) {
cardPickerEl . hidden = true ;
}
}
function appendCardPickerSearchValue ( terms , value ) {
const normalizedValue = normalizeLabelText ( value ) ;
if ( ! normalizedValue ) {
return ;
}
terms . add ( normalizeKey ( normalizedValue ) ) ;
}
function appendCardPickerSearchValuesFromObject ( terms , value , depth = 0 ) {
if ( depth > 4 || value === null || value === undefined ) {
return ;
}
if ( Array . isArray ( value ) ) {
value . forEach ( ( entry ) => {
appendCardPickerSearchValuesFromObject ( terms , entry , depth + 1 ) ;
} ) ;
return ;
}
if ( typeof value === "object" ) {
Object . values ( value ) . forEach ( ( entry ) => {
appendCardPickerSearchValuesFromObject ( terms , entry , depth + 1 ) ;
} ) ;
return ;
}
appendCardPickerSearchValue ( terms , value ) ;
}
function buildCardPickerSearchText ( card ) {
const terms = new Set ( ) ;
[
getDisplayCardName ( card ) ,
card ? . name ,
card ? . arcana ,
card ? . rank ,
card ? . suit ,
card ? . number ,
card ? . summary ,
card ? . hebrewLetterId ,
card ? . kabbalahPathNumber ,
buildHebrewLabel ( card ) ? . primary ,
buildHebrewLabel ( card ) ? . secondary ,
buildPlanetLabel ( card ) ? . primary ,
buildMajorZodiacLabel ( card ) ? . primary ,
buildTrumpNumberLabel ( card ) ? . primary ,
buildPathNumberLabel ( card ) ? . primary ,
buildZodiacLabel ( card ) ? . primary ,
buildZodiacLabel ( card ) ? . secondary ,
buildDecanLabel ( card ) ? . primary ,
buildDecanLabel ( card ) ? . secondary ,
buildDateLabel ( card ) ? . primary ,
buildDateLabel ( card ) ? . secondary ,
buildMonthLabel ( card ) ? . primary ,
buildRulerLabel ( card ) ? . primary ,
getCardOverlayDate ( card )
] . forEach ( ( value ) => {
appendCardPickerSearchValue ( terms , value ) ;
} ) ;
appendCardPickerSearchValuesFromObject ( terms , card ? . relations || [ ] ) ;
return Array . from ( terms ) . join ( " " ) ;
}
function buildCardPickerSections ( ) {
const cards = getCards ( ) ;
const queryTerms = normalizeKey ( state . cardPicker . query ) . split ( /\s+/ ) . filter ( Boolean ) ;
const matchesQuery = ( card ) => {
if ( ! queryTerms . length ) {
return true ;
}
const haystack = buildCardPickerSearchText ( card ) ;
return queryTerms . every ( ( term ) => haystack . includes ( term ) ) ;
} ;
const majorCards = cards
. filter ( ( card ) => card ? . arcana === "Major" && matchesQuery ( card ) )
. sort ( ( left , right ) => Number ( left ? . number ) - Number ( right ? . number ) ) ;
const minorSuitGroups = EXTRA _SUIT _ORDER . map ( ( suitId ) => {
const items = cards
. filter ( ( card ) => card ? . arcana === "Minor"
&& MINOR _RANKS . has ( String ( card ? . rank || "" ) )
&& normalizeKey ( card ? . suit ) === suitId
&& matchesQuery ( card ) )
. sort ( ( left , right ) => getMinorRankSortIndex ( left ? . rank ) - getMinorRankSortIndex ( right ? . rank ) ) ;
return {
title : normalizeLabelText ( items [ 0 ] ? . suit || suitId ) ,
items
} ;
} ) . filter ( ( group ) => group . items . length ) ;
const courtSuitGroups = EXTRA _SUIT _ORDER . map ( ( suitId ) => {
const items = cards
. filter ( ( card ) => card ? . arcana === "Minor"
&& ! MINOR _RANKS . has ( String ( card ? . rank || "" ) )
&& String ( card ? . rank || "" ) . trim ( ) !== "Ace"
&& normalizeKey ( card ? . suit ) === suitId
&& matchesQuery ( card ) )
. sort ( ( left , right ) => getCourtRankSortIndex ( left ? . rank ) - getCourtRankSortIndex ( right ? . rank ) ) ;
return {
title : normalizeLabelText ( items [ 0 ] ? . suit || suitId ) ,
items
} ;
} ) . filter ( ( group ) => group . items . length ) ;
const aceCards = cards
. filter ( ( card ) => card ? . arcana === "Minor" && String ( card ? . rank || "" ) . trim ( ) === "Ace" && matchesQuery ( card ) )
. sort ( ( left , right ) => getSuitSortIndex ( left ? . suit ) - getSuitSortIndex ( right ? . suit ) ) ;
return [
{ title : "Major Arcana" , groups : [ { title : "" , items : majorCards } ] } ,
{ title : "Minor Arcana" , groups : minorSuitGroups } ,
{ title : "Court Cards" , groups : courtSuitGroups } ,
{ title : "Aces" , groups : [ { title : "" , items : aceCards } ] }
] . filter ( ( section ) => section . groups . some ( ( group ) => group . items . length ) ) ;
}
function renderCardPickerSections ( ) {
if ( ! ( cardPickerSectionsEl instanceof HTMLElement ) ) {
return ;
}
cardPickerSectionsEl . replaceChildren ( ) ;
const sections = buildCardPickerSections ( ) ;
if ( ! sections . length ) {
const emptyEl = document . createElement ( "div" ) ;
emptyEl . className = "tarot-frame-card-picker-empty" ;
emptyEl . textContent = "No tarot cards match that search." ;
cardPickerSectionsEl . appendChild ( emptyEl ) ;
return ;
}
sections . forEach ( ( section ) => {
const sectionEl = document . createElement ( "section" ) ;
sectionEl . className = "tarot-frame-card-picker-section" ;
const sectionTitleEl = document . createElement ( "h4" ) ;
sectionTitleEl . className = "tarot-frame-card-picker-section-title" ;
sectionTitleEl . textContent = section . title ;
sectionEl . appendChild ( sectionTitleEl ) ;
section . groups . forEach ( ( group ) => {
if ( ! group . items . length ) {
return ;
}
if ( group . title ) {
const groupTitleEl = document . createElement ( "div" ) ;
groupTitleEl . className = "tarot-frame-card-picker-subtitle" ;
groupTitleEl . textContent = group . title ;
sectionEl . appendChild ( groupTitleEl ) ;
}
const gridEl = document . createElement ( "div" ) ;
gridEl . className = "tarot-frame-card-picker-grid" ;
group . items . forEach ( ( card ) => {
const buttonEl = document . createElement ( "button" ) ;
buttonEl . type = "button" ;
buttonEl . className = "tarot-frame-card-picker-option" ;
buttonEl . dataset . cardId = getCardId ( card ) ;
const titleEl = document . createElement ( "strong" ) ;
titleEl . textContent = getDisplayCardName ( card ) ;
const metaEl = document . createElement ( "span" ) ;
metaEl . textContent = card ? . arcana === "Major"
? ` Trump ${ Number . isFinite ( Number ( card ? . number ) ) ? Number ( card . number ) : "" } ` . trim ( )
: [ normalizeLabelText ( card ? . rank ) , normalizeLabelText ( card ? . suit ) ] . filter ( Boolean ) . join ( " · " ) ;
buttonEl . append ( titleEl , metaEl ) ;
gridEl . appendChild ( buttonEl ) ;
} ) ;
sectionEl . appendChild ( gridEl ) ;
} ) ;
cardPickerSectionsEl . appendChild ( sectionEl ) ;
} ) ;
}
function positionCardPicker ( anchorX , anchorY ) {
if ( ! ( cardPickerEl instanceof HTMLElement ) ) {
return ;
}
cardPickerEl . hidden = false ;
cardPickerEl . style . visibility = "hidden" ;
requestAnimationFrame ( ( ) => {
if ( ! ( cardPickerEl instanceof HTMLElement ) ) {
return ;
}
const panelWidth = cardPickerEl . offsetWidth || 360 ;
const panelHeight = cardPickerEl . offsetHeight || 420 ;
const margin = 12 ;
const viewportWidth = window . innerWidth ;
const viewportHeight = window . innerHeight ;
let left = Math . min ( Math . max ( anchorX + 10 , margin ) , viewportWidth - panelWidth - margin ) ;
let top = Math . min ( Math . max ( anchorY + 10 , margin ) , viewportHeight - panelHeight - margin ) ;
if ( top > viewportHeight - panelHeight - margin ) {
top = Math . max ( margin , anchorY - panelHeight - 10 ) ;
}
if ( viewportWidth <= 760 ) {
left = Math . max ( margin , Math . round ( ( viewportWidth - panelWidth ) / 2 ) ) ;
}
cardPickerEl . style . left = ` ${ left } px ` ;
cardPickerEl . style . top = ` ${ top } px ` ;
cardPickerEl . style . visibility = "visible" ;
} ) ;
}
function openCardPicker ( slotId , anchorX , anchorY ) {
createCardPickerElements ( ) ;
state . cardPicker . open = true ;
state . cardPicker . slotId = String ( slotId || "" ) . trim ( ) ;
if ( cardPickerTitleEl ) {
cardPickerTitleEl . textContent = ` Place Tarot Card at ${ describeSlot ( slotId ) } ` ;
}
if ( cardPickerSearchEl ) {
cardPickerSearchEl . value = state . cardPicker . query ;
}
renderCardPickerSections ( ) ;
positionCardPicker ( anchorX , anchorY ) ;
requestAnimationFrame ( ( ) => {
cardPickerSearchEl ? . focus ( { preventScroll : true } ) ;
} ) ;
}
function findAssignedSlotIdByCardId ( cardId ) {
const targetCardId = String ( cardId || "" ) . trim ( ) ;
if ( ! targetCardId ) {
return "" ;
}
for ( const [ slotId , assignedCardId ] of state . slotAssignments . entries ( ) ) {
if ( String ( assignedCardId || "" ) . trim ( ) === targetCardId ) {
return slotId ;
}
}
return "" ;
}
function placeCardInSlot ( slotId , cardId ) {
const targetSlotId = String ( slotId || "" ) . trim ( ) ;
const targetCardId = String ( cardId || "" ) . trim ( ) ;
if ( ! isValidSlotId ( targetSlotId ) || ! targetCardId ) {
return ;
}
const card = getCardMap ( getCards ( ) ) . get ( targetCardId ) || null ;
if ( ! card ) {
return ;
}
const previousSlotId = findAssignedSlotIdByCardId ( targetCardId ) ;
if ( previousSlotId && previousSlotId !== targetSlotId ) {
state . slotAssignments . delete ( previousSlotId ) ;
}
state . slotAssignments . set ( targetSlotId , targetCardId ) ;
state . layoutReady = true ;
render ( { preserveViewport : true } ) ;
syncControls ( ) ;
setStatus ( ` ${ getDisplayCardName ( card ) } placed at ${ describeSlot ( targetSlotId ) } . ` ) ;
}
function clearGrid ( ) {
if ( ! state . slotAssignments . size ) {
setStatus ( "The Tarot Frame grid is already empty." ) ;
return ;
}
const shouldClear = window . confirm ( "Clear every card from the current Tarot Frame grid?" ) ;
if ( ! shouldClear ) {
return ;
}
state . slotAssignments . clear ( ) ;
state . layoutReady = true ;
render ( ) ;
syncControls ( ) ;
setStatus ( "Tarot Frame grid cleared. Use the card picker or a layout preset to repopulate it." ) ;
}
function clearLongPressGesture ( ) {
if ( state . longPress ? . timerId ) {
window . clearTimeout ( state . longPress . timerId ) ;
}
document . removeEventListener ( "pointermove" , handleLongPressPointerMove ) ;
document . removeEventListener ( "pointerup" , handleLongPressPointerUp ) ;
document . removeEventListener ( "pointercancel" , handleLongPressPointerCancel ) ;
state . longPress = null ;
}
function scheduleLongPress ( slotId , event ) {
clearLongPressGesture ( ) ;
document . addEventListener ( "pointermove" , handleLongPressPointerMove ) ;
document . addEventListener ( "pointerup" , handleLongPressPointerUp ) ;
document . addEventListener ( "pointercancel" , handleLongPressPointerCancel ) ;
state . longPress = {
pointerId : event . pointerId ,
slotId ,
startX : event . clientX ,
startY : event . clientY ,
timerId : window . setTimeout ( ( ) => {
const activeGesture = state . longPress ;
clearLongPressGesture ( ) ;
if ( ! activeGesture ) {
return ;
}
state . suppressClick = true ;
openCardPicker ( activeGesture . slotId , activeGesture . startX , activeGesture . startY ) ;
} , FRAME _LONG _PRESS _DELAY _MS )
} ;
}
function updateLongPress ( event ) {
if ( ! state . longPress || event . pointerId !== state . longPress . pointerId ) {
return ;
}
if ( Math . hypot ( event . clientX - state . longPress . startX , event . clientY - state . longPress . startY ) > FRAME _LONG _PRESS _MOVE _TOLERANCE ) {
clearLongPressGesture ( ) ;
}
}
function finishLongPress ( event ) {
if ( ! state . longPress || event . pointerId !== state . longPress . pointerId ) {
return ;
}
clearLongPressGesture ( ) ;
2026-04-01 16:08:52 -07:00
}
2026-04-04 03:39:29 -07:00
function handleLongPressPointerMove ( event ) {
updateLongPress ( event ) ;
2026-04-01 12:31:56 -07:00
}
2026-04-04 03:39:29 -07:00
function handleLongPressPointerUp ( event ) {
finishLongPress ( event ) ;
2026-04-01 12:31:56 -07:00
}
2026-04-04 03:39:29 -07:00
function handleLongPressPointerCancel ( event ) {
finishLongPress ( event ) ;
2026-04-01 12:31:56 -07:00
}
2026-04-04 03:39:29 -07:00
function getTouchPanAnchor ( touches ) {
if ( ! touches || touches . length < 1 ) {
return null ;
}
let totalX = 0 ;
let totalY = 0 ;
let count = 0 ;
Array . from ( touches ) . forEach ( ( touch ) => {
if ( ! touch ) {
return ;
}
totalX += Number ( touch . clientX ) || 0 ;
totalY += Number ( touch . clientY ) || 0 ;
count += 1 ;
} ) ;
if ( ! count ) {
return null ;
}
return {
x : totalX / count ,
y : totalY / count
} ;
2026-04-01 12:31:56 -07:00
}
2026-04-04 03:39:29 -07:00
function getTouchDistance ( touches ) {
if ( ! touches || touches . length < 2 ) {
2026-04-01 12:31:56 -07:00
return 0 ;
}
2026-04-04 03:39:29 -07:00
const first = touches [ 0 ] ;
const second = touches [ 1 ] ;
if ( ! first || ! second ) {
return 0 ;
2026-04-01 12:31:56 -07:00
}
2026-04-04 03:39:29 -07:00
return Math . hypot ( Number ( first . clientX ) - Number ( second . clientX ) , Number ( first . clientY ) - Number ( second . clientY ) ) ;
}
function startPanGesture ( event , options = { } ) {
const viewportEl = getGridViewportElement ( ) ;
if ( ! ( viewportEl instanceof HTMLElement ) ) {
return ;
}
clearLongPressGesture ( ) ;
if ( state . drag ) {
cleanupDrag ( ) ;
}
const source = String ( options . source || "pointer" ) . trim ( ) || "pointer" ;
const startX = Number ( options . startX ? ? event ? . clientX ) ;
const startY = Number ( options . startY ? ? event ? . clientY ) ;
state . panGesture = {
source ,
pointerId : source === "pointer" ? event ? . pointerId : null ,
startX ,
startY ,
startScrollLeft : viewportEl . scrollLeft ,
startScrollTop : viewportEl . scrollTop
} ;
updateViewportInteractionState ( ) ;
syncActiveTouchGestureCapture ( ) ;
if ( source === "pointer" ) {
document . addEventListener ( "pointermove" , handlePanPointerMove ) ;
document . addEventListener ( "pointerup" , handlePanPointerUp ) ;
document . addEventListener ( "pointercancel" , handlePanPointerCancel ) ;
event ? . preventDefault ? . ( ) ;
2026-04-01 12:31:56 -07:00
}
}
2026-04-04 03:39:29 -07:00
function startTouchPanGesture ( event ) {
const anchor = getTouchPanAnchor ( event ? . touches ) ;
if ( ! anchor ) {
return ;
}
2026-04-04 12:15:52 -07:00
removeOrphanedDragGhosts ( ) ;
2026-04-04 03:39:29 -07:00
startPanGesture ( null , {
source : "touch" ,
startX : anchor . x ,
startY : anchor . y
} ) ;
state . suppressClick = true ;
event . preventDefault ( ) ;
2026-04-01 12:31:56 -07:00
}
2026-04-04 03:39:29 -07:00
function startTouchPinchGesture ( event ) {
const anchor = getTouchPanAnchor ( event ? . touches ) ;
const distance = getTouchDistance ( event ? . touches ) ;
const viewportEl = getGridViewportElement ( ) ;
if ( ! anchor || ! ( distance > 0 ) || ! ( viewportEl instanceof HTMLElement ) ) {
return ;
2026-04-01 12:31:56 -07:00
}
2026-04-04 12:15:52 -07:00
removeOrphanedDragGhosts ( ) ;
2026-04-04 03:39:29 -07:00
clearLongPressGesture ( ) ;
if ( state . drag ) {
cleanupDrag ( ) ;
2026-04-01 12:31:56 -07:00
}
2026-04-04 03:39:29 -07:00
state . pinchGesture = {
startDistance : distance ,
startScale : getGridZoomScale ( ) ,
startAnchorX : anchor . x ,
startAnchorY : anchor . y ,
startScrollLeft : viewportEl . scrollLeft ,
startScrollTop : viewportEl . scrollTop
} ;
finishPanGesture ( ) ;
syncActiveTouchGestureCapture ( ) ;
updateViewportInteractionState ( ) ;
state . suppressClick = true ;
event . preventDefault ( ) ;
}
function finishTouchPinchGesture ( ) {
state . pinchGesture = null ;
syncActiveTouchGestureCapture ( ) ;
updateViewportInteractionState ( ) ;
}
function finishPanGesture ( ) {
if ( ! state . panGesture ) {
return ;
2026-04-01 12:31:56 -07:00
}
2026-04-04 03:39:29 -07:00
state . panGesture = null ;
document . removeEventListener ( "pointermove" , handlePanPointerMove ) ;
document . removeEventListener ( "pointerup" , handlePanPointerUp ) ;
document . removeEventListener ( "pointercancel" , handlePanPointerCancel ) ;
syncActiveTouchGestureCapture ( ) ;
updateViewportInteractionState ( ) ;
2026-04-01 12:31:56 -07:00
}
2026-04-04 03:39:29 -07:00
function handlePanPointerMove ( event ) {
if ( ! state . panGesture || state . panGesture . source !== "pointer" || event . pointerId !== state . panGesture . pointerId ) {
return ;
}
const viewportEl = getGridViewportElement ( ) ;
if ( ! ( viewportEl instanceof HTMLElement ) ) {
finishPanGesture ( ) ;
return ;
}
viewportEl . scrollLeft = state . panGesture . startScrollLeft - ( event . clientX - state . panGesture . startX ) ;
viewportEl . scrollTop = state . panGesture . startScrollTop - ( event . clientY - state . panGesture . startY ) ;
state . suppressClick = true ;
event . preventDefault ( ) ;
2026-04-01 12:31:56 -07:00
}
2026-04-04 03:39:29 -07:00
function handlePanPointerUp ( event ) {
if ( ! state . panGesture || state . panGesture . source !== "pointer" || event . pointerId !== state . panGesture . pointerId ) {
return ;
}
finishPanGesture ( ) ;
2026-04-01 19:26:38 -07:00
}
2026-04-04 03:39:29 -07:00
function handlePanPointerCancel ( event ) {
if ( ! state . panGesture || state . panGesture . source !== "pointer" || event . pointerId !== state . panGesture . pointerId ) {
return ;
}
finishPanGesture ( ) ;
2026-04-01 19:26:38 -07:00
}
2026-04-04 03:39:29 -07:00
function handleBoardTouchStart ( event ) {
2026-04-04 12:15:52 -07:00
blurLayoutNoteForBoardInteraction ( event . target ) ;
2026-04-04 03:39:29 -07:00
if ( event . touches . length >= 3 ) {
startTouchPanGesture ( event ) ;
return ;
}
if ( event . touches . length !== 2 ) {
return ;
}
startTouchPinchGesture ( event ) ;
2026-04-01 12:31:56 -07:00
}
2026-04-04 03:39:29 -07:00
function handleBoardTouchMove ( event ) {
if ( state . pinchGesture ) {
if ( event . touches . length >= 3 ) {
finishTouchPinchGesture ( ) ;
startTouchPanGesture ( event ) ;
return ;
}
if ( event . touches . length !== 2 ) {
finishTouchPinchGesture ( ) ;
return ;
}
const anchor = getTouchPanAnchor ( event . touches ) ;
const distance = getTouchDistance ( event . touches ) ;
const viewportEl = getGridViewportElement ( ) ;
if ( ! anchor || ! ( distance > 0 ) || ! ( viewportEl instanceof HTMLElement ) ) {
finishTouchPinchGesture ( ) ;
return ;
}
const pinchRatio = distance / state . pinchGesture . startDistance ;
const nextScale = clampFrameGridZoomScale ( state . pinchGesture . startScale * pinchRatio ) ;
setGridZoomScale ( nextScale , {
preserveViewport : false ,
anchorClientX : anchor . x ,
anchorClientY : anchor . y ,
statusMessage : ""
} ) ;
state . suppressClick = true ;
event . preventDefault ( ) ;
return ;
}
if ( ! state . panGesture || state . panGesture . source !== "touch" ) {
return ;
}
if ( event . touches . length === 2 ) {
finishPanGesture ( ) ;
startTouchPinchGesture ( event ) ;
return ;
}
if ( event . touches . length < 3 ) {
finishPanGesture ( ) ;
return ;
}
const anchor = getTouchPanAnchor ( event . touches ) ;
if ( ! anchor ) {
finishPanGesture ( ) ;
return ;
}
const viewportEl = getGridViewportElement ( ) ;
if ( ! ( viewportEl instanceof HTMLElement ) ) {
finishPanGesture ( ) ;
return ;
}
viewportEl . scrollLeft = state . panGesture . startScrollLeft - ( anchor . x - state . panGesture . startX ) ;
viewportEl . scrollTop = state . panGesture . startScrollTop - ( anchor . y - state . panGesture . startY ) ;
state . suppressClick = true ;
event . preventDefault ( ) ;
}
function handleBoardTouchEnd ( event ) {
if ( state . pinchGesture ) {
if ( event . touches . length >= 3 ) {
finishTouchPinchGesture ( ) ;
startTouchPanGesture ( event ) ;
return ;
}
if ( event . touches . length === 2 ) {
startTouchPinchGesture ( event ) ;
return ;
}
finishTouchPinchGesture ( ) ;
return ;
}
if ( ! state . panGesture || state . panGesture . source !== "touch" ) {
return ;
}
if ( event . touches . length >= 3 ) {
startTouchPanGesture ( event ) ;
return ;
}
if ( event . touches . length === 2 ) {
finishPanGesture ( ) ;
startTouchPinchGesture ( event ) ;
return ;
}
finishPanGesture ( ) ;
}
function handleBoardTouchCancel ( ) {
finishTouchPinchGesture ( ) ;
if ( ! state . panGesture || state . panGesture . source !== "touch" ) {
return ;
}
finishPanGesture ( ) ;
2026-04-01 12:31:56 -07:00
}
2026-04-01 16:08:52 -07:00
function normalizeLookupCardName ( value ) {
return String ( value || "" )
. trim ( )
. toLowerCase ( )
. replace ( /\s+/g , " " )
. replace ( /\b(pentacles?|coins?)\b/g , "disks" ) ;
}
2026-04-01 12:31:56 -07:00
function getCards ( ) {
const cards = config . getCards ? . ( ) ;
return Array . isArray ( cards ) ? cards : [ ] ;
}
function getCardId ( card ) {
return String ( card ? . id || "" ) . trim ( ) ;
}
function getCardMap ( cards ) {
return new Map ( cards . map ( ( card ) => [ getCardId ( card ) , card ] ) ) ;
}
function getRelation ( card , type ) {
return Array . isArray ( card ? . relations )
? card . relations . find ( ( relation ) => relation ? . type === type ) || null
: null ;
}
2026-04-01 16:08:52 -07:00
function getRelations ( card , type ) {
return Array . isArray ( card ? . relations )
? card . relations . filter ( ( relation ) => relation ? . type === type )
: [ ] ;
}
2026-04-01 12:31:56 -07:00
function parseMonthDayToken ( token ) {
const match = String ( token || "" ) . trim ( ) . match ( /^(\d{2})-(\d{2})$/ ) ;
if ( ! match ) {
return null ;
}
const month = Number ( match [ 1 ] ) ;
const day = Number ( match [ 2 ] ) ;
if ( ! Number . isInteger ( month ) || ! Number . isInteger ( day ) || month < 1 || month > 12 ) {
return null ;
}
return { month , day } ;
}
function formatMonthDay ( token ) {
const parsed = parseMonthDayToken ( token ) ;
if ( ! parsed ) {
return "" ;
}
return ` ${ MONTH _ABBR [ parsed . month - 1 ] } ${ parsed . day } ` ;
}
function decrementToken ( token ) {
const parsed = parseMonthDayToken ( token ) ;
if ( ! parsed ) {
return null ;
}
if ( parsed . day > 1 ) {
return ` ${ String ( parsed . month ) . padStart ( 2 , "0" ) } - ${ String ( parsed . day - 1 ) . padStart ( 2 , "0" ) } ` ;
}
const previousMonth = parsed . month === 1 ? 12 : parsed . month - 1 ;
const previousDay = MONTH _LENGTHS [ previousMonth - 1 ] ;
return ` ${ String ( previousMonth ) . padStart ( 2 , "0" ) } - ${ String ( previousDay ) . padStart ( 2 , "0" ) } ` ;
}
function formatDateRange ( startToken , endToken ) {
const start = parseMonthDayToken ( startToken ) ;
const end = parseMonthDayToken ( endToken ) ;
if ( ! start || ! end ) {
return "" ;
}
const startMonth = MONTH _ABBR [ start . month - 1 ] ;
const endMonth = MONTH _ABBR [ end . month - 1 ] ;
if ( start . month === end . month ) {
return ` ${ startMonth } ${ start . day } - ${ end . day } ` ;
}
return ` ${ startMonth } ${ start . day } - ${ endMonth } ${ end . day } ` ;
}
function toOrdinalDay ( token ) {
const parsed = parseMonthDayToken ( token ) ;
if ( ! parsed ) {
return Number . POSITIVE _INFINITY ;
}
const daysBeforeMonth = MONTH _LENGTHS . slice ( 0 , parsed . month - 1 ) . reduce ( ( total , length ) => total + length , 0 ) ;
return daysBeforeMonth + parsed . day ;
}
function getCyclicDayValue ( token , cycleStartToken ) {
const value = toOrdinalDay ( token ) ;
const cycleStart = toOrdinalDay ( cycleStartToken ) ;
if ( ! Number . isFinite ( value ) || ! Number . isFinite ( cycleStart ) ) {
return Number . POSITIVE _INFINITY ;
}
return ( value - cycleStart + 365 ) % 365 ;
}
function compareDateTokens ( leftToken , rightToken , cycleStartToken ) {
return getCyclicDayValue ( leftToken , cycleStartToken ) - getCyclicDayValue ( rightToken , cycleStartToken ) ;
}
2026-04-01 16:08:52 -07:00
function assignCardsToPositions ( placements , positions , orderedCards ) {
( Array . isArray ( positions ) ? positions : [ ] ) . forEach ( ( position , index ) => {
const card = orderedCards [ index ] || null ;
if ( ! card ) {
return ;
}
placements . push ( {
row : position . row ,
column : position . column ,
cardId : getCardId ( card )
} ) ;
} ) ;
}
function buildMinorCardName ( rankNumber , suit ) {
const rankName = ( {
1 : "Ace" ,
2 : "Two" ,
3 : "Three" ,
4 : "Four" ,
5 : "Five" ,
6 : "Six" ,
7 : "Seven" ,
8 : "Eight" ,
9 : "Nine" ,
10 : "Ten"
} ) [ Number ( rankNumber ) ] ;
const suitName = String ( suit || "" ) . trim ( ) ;
return rankName && suitName ? ` ${ rankName } of ${ suitName } ` : "" ;
}
function buildCourtCardName ( rank , suit ) {
const rankName = String ( rank || "" ) . trim ( ) ;
const suitName = String ( suit || "" ) . trim ( ) ;
return rankName && suitName ? ` ${ rankName } of ${ suitName } ` : "" ;
}
function getCardLookupMap ( cards ) {
const lookup = new Map ( ) ;
cards . forEach ( ( card ) => {
const key = normalizeLookupCardName ( card ? . name ) ;
if ( key ) {
lookup . set ( key , card ) ;
}
} ) ;
return lookup ;
}
function findCardByLookupName ( cardLookupMap , cardName ) {
return cardLookupMap . get ( normalizeLookupCardName ( cardName ) ) || null ;
}
function findMajorCardByTrumpNumber ( cards , trumpNumber ) {
const target = Number ( trumpNumber ) ;
return cards . find ( ( card ) => card ? . arcana === "Major" && Number ( card ? . number ) === target ) || null ;
}
function buildHousePlacements ( cards ) {
const placements = [ ] ;
const lookupMap = getCardLookupMap ( cards ) ;
HOUSE _TRUMP _ROWS . forEach ( ( trumpNumbers , rowIndex ) => {
const rowCards = trumpNumbers . map ( ( trumpNumber ) => findMajorCardByTrumpNumber ( cards , trumpNumber ) ) ;
const startColumn = Math . floor ( ( MASTER _GRID _SIZE - rowCards . length ) / 2 ) + 1 ;
assignCardsToPositions (
placements ,
rowCards . map ( ( card , index ) => ( { row : HOUSE _TRUMP _GRID _ROWS [ rowIndex ] , column : startColumn + index } ) ) ,
rowCards
) ;
} ) ;
HOUSE _MINOR _NUMBER _BANDS . forEach ( ( numbers , rowIndex ) => {
const row = HOUSE _BOTTOM _START _ROW + rowIndex ;
const leftCards = numbers . map ( ( rankNumber ) => findCardByLookupName ( lookupMap , buildMinorCardName ( rankNumber , HOUSE _LEFT _SUITS [ rowIndex ] ) ) ) ;
const rightCards = numbers . map ( ( rankNumber ) => findCardByLookupName ( lookupMap , buildMinorCardName ( rankNumber , HOUSE _RIGHT _SUITS [ rowIndex ] ) ) ) ;
assignCardsToPositions (
placements ,
leftCards . map ( ( card , index ) => ( { row , column : HOUSE _LEFT _START _COLUMN + index } ) ) ,
leftCards
) ;
assignCardsToPositions (
placements ,
rightCards . map ( ( card , index ) => ( { row , column : HOUSE _RIGHT _START _COLUMN + index } ) ) ,
rightCards
) ;
} ) ;
HOUSE _MIDDLE _RANKS . forEach ( ( rank , rowIndex ) => {
const row = HOUSE _BOTTOM _START _ROW + rowIndex ;
const middleCards = HOUSE _MIDDLE _SUITS . map ( ( suit ) => findCardByLookupName ( lookupMap , buildCourtCardName ( rank , suit ) ) ) ;
assignCardsToPositions (
placements ,
middleCards . map ( ( card , index ) => ( { row , column : HOUSE _MIDDLE _START _COLUMN + index } ) ) ,
middleCards
) ;
} ) ;
return placements ;
}
function getLayoutPreset ( layoutId = state . currentLayoutId ) {
2026-04-04 03:39:29 -07:00
return LAYOUT _PRESETS . find ( ( preset ) => preset . id === normalizeKey ( layoutId ) ) || null ;
}
function getSavedLayout ( layoutId = state . currentLayoutId ) {
return state . customLayouts . find ( ( layout ) => layout . id === String ( layoutId || "" ) . trim ( ) ) || null ;
}
function getLayoutDefinition ( layoutId = state . currentLayoutId ) {
return getSavedLayout ( layoutId ) || getLayoutPreset ( layoutId ) || LAYOUT _PRESETS [ 0 ] ;
}
function isCustomLayout ( layoutId = state . currentLayoutId ) {
return Boolean ( getSavedLayout ( layoutId ) ) ;
2026-04-01 16:08:52 -07:00
}
2026-04-01 12:31:56 -07:00
function buildCardSignature ( cards ) {
return cards . map ( ( card ) => getCardId ( card ) ) . filter ( Boolean ) . sort ( ) . join ( "|" ) ;
}
function resolveDeckOptions ( card ) {
const deckId = String ( tarotCardImages . getActiveDeck ? . ( ) || "" ) . trim ( ) ;
const trumpNumber = card ? . arcana === "Major" && Number . isFinite ( Number ( card ? . number ) )
? Number ( card . number )
: undefined ;
if ( ! deckId && ! Number . isFinite ( trumpNumber ) ) {
return null ;
}
return {
... ( deckId ? { deckId } : { } ) ,
... ( Number . isFinite ( trumpNumber ) ? { trumpNumber } : { } )
} ;
}
function resolveCardThumbnail ( card ) {
if ( ! card ) {
return "" ;
}
const deckOptions = resolveDeckOptions ( card ) || undefined ;
return String (
tarotCardImages . resolveTarotCardThumbnail ? . ( card . name , deckOptions )
|| tarotCardImages . resolveTarotCardImage ? . ( card . name , deckOptions )
|| ""
) . trim ( ) ;
}
function getDisplayCardName ( card ) {
const label = tarotCardImages . getTarotCardDisplayName ? . ( card ? . name , resolveDeckOptions ( card ) || undefined ) ;
return String ( label || card ? . name || "Tarot" ) . trim ( ) || "Tarot" ;
}
2026-04-01 16:08:52 -07:00
function toRomanNumeral ( value ) {
let remaining = Number ( value ) ;
if ( ! Number . isFinite ( remaining ) || remaining <= 0 ) {
return "" ;
2026-04-01 12:31:56 -07:00
}
2026-04-01 16:08:52 -07:00
const numerals = [
[ 10 , "X" ] ,
[ 9 , "IX" ] ,
[ 5 , "V" ] ,
[ 4 , "IV" ] ,
[ 1 , "I" ]
] ;
let result = "" ;
numerals . forEach ( ( [ amount , glyph ] ) => {
while ( remaining >= amount ) {
result += glyph ;
remaining -= amount ;
}
} ) ;
return result ;
}
function buildHebrewLabel ( card ) {
const hebrew = card ? . hebrewLetter && typeof card . hebrewLetter === "object"
? card . hebrewLetter
: getRelation ( card , "hebrewLetter" ) ? . data ;
const glyph = normalizeLabelText ( hebrew ? . glyph || hebrew ? . char ) ;
const transliteration = normalizeLabelText ( hebrew ? . latin || hebrew ? . name || card ? . hebrewLetterId ) ;
const primary = glyph || transliteration ;
const secondary = glyph && transliteration ? transliteration : "" ;
return primary ? { primary , secondary , className : "is-top-hebrew" } : null ;
}
function buildPlanetLabel ( card ) {
const relation = getRelation ( card , "planetCorrespondence" )
|| getRelation ( card , "planet" )
|| getRelation ( card , "decanRuler" ) ;
const name = normalizeLabelText ( relation ? . data ? . symbol
? ` ${ relation . data . symbol } ${ relation . data . name || relation . data . planetId || "" } `
: relation ? . data ? . name || relation ? . data ? . planetId || relation ? . id ) ;
return name ? { primary : relation ? . type === "decanRuler" ? ` Ruler: ${ name } ` : ` Planet: ${ name } ` , secondary : "" , className : "" } : null ;
}
function buildMajorZodiacLabel ( card ) {
const relation = getRelation ( card , "zodiacCorrespondence" ) || getRelation ( card , "zodiac" ) ;
const name = normalizeLabelText ( relation ? . data ? . symbol
? ` ${ relation . data . symbol } ${ relation . data . name || relation . data . signName || "" } `
: relation ? . data ? . name || relation ? . data ? . signName || relation ? . id ) ;
return name ? { primary : ` Zodiac: ${ name } ` , secondary : "" , className : "" } : null ;
}
function buildTrumpNumberLabel ( card ) {
const number = Number ( card ? . number ) ;
if ( ! Number . isFinite ( number ) ) {
return null ;
}
return {
primary : ` Trump: ${ number === 0 ? "0" : toRomanNumeral ( Math . trunc ( number ) ) } ` ,
secondary : "" ,
className : ""
} ;
}
function buildPathNumberLabel ( card ) {
const pathNumber = Number ( card ? . kabbalahPathNumber ) ;
return Number . isFinite ( pathNumber )
? { primary : ` Path: ${ Math . trunc ( pathNumber ) } ` , secondary : "" , className : "" }
: null ;
}
function buildZodiacLabel ( card ) {
const zodiacRelation = getRelation ( card , "zodiac" ) ;
const decanRelations = getRelations ( card , "decan" ) ;
const primary = normalizeLabelText (
zodiacRelation ? . data ? . symbol
? ` ${ zodiacRelation . data . symbol } ${ zodiacRelation . data . signName || zodiacRelation . data . name || "" } `
: zodiacRelation ? . data ? . signName || zodiacRelation ? . data ? . name
) ;
if ( primary ) {
const dateRange = normalizeLabelText ( getRelation ( card , "courtDateWindow" ) ? . data ? . dateRange ) ;
return {
primary ,
secondary : dateRange || "" ,
className : ""
} ;
}
if ( decanRelations . length > 0 ) {
const first = decanRelations [ 0 ] ? . data || { } ;
const last = decanRelations [ decanRelations . length - 1 ] ? . data || { } ;
const firstName = normalizeLabelText ( first . signName ) ;
const lastName = normalizeLabelText ( last . signName ) ;
const rangeLabel = firstName && lastName
? ( firstName === lastName ? firstName : ` ${ firstName } -> ${ lastName } ` )
: firstName || lastName ;
const dateRange = normalizeLabelText ( getRelation ( card , "courtDateWindow" ) ? . data ? . dateRange ) ;
return rangeLabel
? { primary : rangeLabel , secondary : dateRange || "" , className : "" }
: null ;
}
return null ;
}
function buildDecanLabel ( card ) {
const decanRelations = getRelations ( card , "decan" ) ;
if ( ! decanRelations . length ) {
return null ;
}
if ( decanRelations . length === 1 ) {
const data = decanRelations [ 0 ] . data || { } ;
const hasDegrees = Number . isFinite ( Number ( data . startDegree ) ) && Number . isFinite ( Number ( data . endDegree ) ) ;
const degreeLabel = hasDegrees ? ` ${ data . startDegree } °- ${ data . endDegree } ° ` : "" ;
const signLabel = normalizeLabelText ( data . signName ) ;
const primary = degreeLabel || signLabel ;
const secondary = degreeLabel && signLabel ? signLabel : normalizeLabelText ( data . dateRange ) ;
return primary ? { primary , secondary , className : "" } : null ;
}
const first = decanRelations [ 0 ] ? . data || { } ;
const last = decanRelations [ decanRelations . length - 1 ] ? . data || { } ;
const firstLabel = normalizeLabelText ( first . signName ) && Number . isFinite ( Number ( first . index ) )
? ` ${ first . signName } ${ toRomanNumeral ( first . index ) } `
: normalizeLabelText ( first . signName ) ;
const lastLabel = normalizeLabelText ( last . signName ) && Number . isFinite ( Number ( last . index ) )
? ` ${ last . signName } ${ toRomanNumeral ( last . index ) } `
: normalizeLabelText ( last . signName ) ;
const primary = firstLabel && lastLabel
? ( firstLabel === lastLabel ? firstLabel : ` ${ firstLabel } -> ${ lastLabel } ` )
: firstLabel || lastLabel ;
const secondary = normalizeLabelText ( getRelation ( card , "courtDateWindow" ) ? . data ? . dateRange ) ;
return primary ? { primary , secondary , className : "" } : null ;
}
function buildDateLabel ( card ) {
const dateRange = normalizeLabelText (
getRelation ( card , "courtDateWindow" ) ? . data ? . dateRange
|| getRelation ( card , "decan" ) ? . data ? . dateRange
|| getRelation ( card , "calendarMonth" ) ? . data ? . dateRange
|| getCardOverlayDate ( card )
|| getRelation ( card , "calendarMonth" ) ? . data ? . name
) ;
const secondary = normalizeLabelText (
getRelation ( card , "calendarMonth" ) ? . data ? . name
|| getRelation ( card , "decan" ) ? . data ? . signName
|| getRelation ( card , "zodiacCorrespondence" ) ? . data ? . name
|| getRelation ( card , "zodiac" ) ? . data ? . name
) ;
return dateRange
? { primary : dateRange , secondary : secondary && secondary !== dateRange ? secondary : "" , className : "" }
: null ;
}
function buildMonthLabel ( card ) {
const names = [ ] ;
const seen = new Set ( ) ;
getRelations ( card , "calendarMonth" ) . forEach ( ( relation ) => {
const name = normalizeLabelText ( relation ? . data ? . name ) ;
const key = name . toLowerCase ( ) ;
if ( name && ! seen . has ( key ) ) {
seen . add ( key ) ;
names . push ( name ) ;
}
} ) ;
return names . length ? { primary : ` Month: ${ names . join ( "/" ) } ` , secondary : "" , className : "" } : null ;
}
function buildRulerLabel ( card ) {
const names = [ ] ;
const seen = new Set ( ) ;
getRelations ( card , "decanRuler" ) . forEach ( ( relation ) => {
const name = normalizeLabelText (
relation ? . data ? . symbol
? ` ${ relation . data . symbol } ${ relation . data . name || relation . data . planetId || "" } `
: relation ? . data ? . name || relation ? . data ? . planetId
) ;
const key = name . toLowerCase ( ) ;
if ( name && ! seen . has ( key ) ) {
seen . add ( key ) ;
names . push ( name ) ;
}
} ) ;
return names . length ? { primary : ` Ruler: ${ names . join ( "/" ) } ` , secondary : "" , className : "" } : null ;
}
function getHouseTopInfoModeEnabled ( mode ) {
return Boolean ( config . getHouseTopInfoModes ? . ( ) ? . [ mode ] ) ;
}
function getHouseBottomInfoModeEnabled ( mode ) {
return Boolean ( config . getHouseBottomInfoModes ? . ( ) ? . [ mode ] ) ;
}
function buildHouseTopLabel ( card ) {
const lines = [ ] ;
const seen = new Set ( ) ;
const pushLine = ( value ) => {
const text = normalizeLabelText ( value ) ;
const key = text . toLowerCase ( ) ;
if ( text && ! seen . has ( key ) ) {
seen . add ( key ) ;
lines . push ( text ) ;
}
} ;
if ( getHouseTopInfoModeEnabled ( "hebrew" ) ) {
const hebrew = buildHebrewLabel ( card ) ;
pushLine ( hebrew ? . primary ) ;
pushLine ( hebrew ? . secondary ) ;
}
if ( getHouseTopInfoModeEnabled ( "planet" ) ) {
pushLine ( buildPlanetLabel ( card ) ? . primary ) ;
}
if ( getHouseTopInfoModeEnabled ( "zodiac" ) ) {
pushLine ( buildMajorZodiacLabel ( card ) ? . primary ) ;
}
if ( getHouseTopInfoModeEnabled ( "trump" ) ) {
pushLine ( buildTrumpNumberLabel ( card ) ? . primary ) ;
}
if ( getHouseTopInfoModeEnabled ( "path" ) ) {
pushLine ( buildPathNumberLabel ( card ) ? . primary ) ;
}
if ( getHouseTopInfoModeEnabled ( "date" ) ) {
pushLine ( buildDateLabel ( card ) ? . primary ) ;
}
if ( ! lines . length ) {
return null ;
}
const hasHebrew = getHouseTopInfoModeEnabled ( "hebrew" ) && Boolean ( buildHebrewLabel ( card ) ? . primary ) ;
return {
primary : lines [ 0 ] ,
secondary : lines . slice ( 1 ) . join ( " · " ) ,
className : ` ${ lines . length >= 3 ? "is-dense" : "" } ${ hasHebrew ? " is-top-hebrew" : "" } ` . trim ( )
} ;
}
function buildHouseBottomLabel ( card ) {
const lines = [ ] ;
const seen = new Set ( ) ;
const pushLine = ( value ) => {
const text = normalizeLabelText ( value ) ;
const key = text . toLowerCase ( ) ;
if ( text && ! seen . has ( key ) ) {
seen . add ( key ) ;
lines . push ( text ) ;
}
} ;
if ( getHouseBottomInfoModeEnabled ( "zodiac" ) ) {
pushLine ( buildZodiacLabel ( card ) ? . primary ) ;
}
if ( getHouseBottomInfoModeEnabled ( "decan" ) ) {
const decanLabel = buildDecanLabel ( card ) ;
pushLine ( decanLabel ? . primary ) ;
if ( ! getHouseBottomInfoModeEnabled ( "date" ) ) {
pushLine ( decanLabel ? . secondary ) ;
}
}
if ( getHouseBottomInfoModeEnabled ( "month" ) ) {
pushLine ( buildMonthLabel ( card ) ? . primary ) ;
}
if ( getHouseBottomInfoModeEnabled ( "ruler" ) ) {
pushLine ( buildRulerLabel ( card ) ? . primary ) ;
}
if ( getHouseBottomInfoModeEnabled ( "date" ) ) {
pushLine ( buildDateLabel ( card ) ? . primary ) ;
}
if ( ! lines . length ) {
return null ;
}
return {
primary : lines [ 0 ] ,
secondary : lines . slice ( 1 ) . join ( " · " ) ,
className : lines . length >= 3 ? "is-dense" : ""
} ;
}
function buildHouseLabel ( card ) {
if ( ! card ) {
return null ;
}
return card . arcana === "Major" ? buildHouseTopLabel ( card ) : buildHouseBottomLabel ( card ) ;
}
function shouldShowCardImage ( card ) {
2026-04-01 19:26:38 -07:00
if ( ! card ) {
2026-04-01 16:08:52 -07:00
return true ;
}
if ( card . arcana === "Major" ) {
return config . getHouseTopCardsVisible ? . ( ) !== false ;
}
return config . getHouseBottomCardsVisible ? . ( ) !== false ;
}
function buildCardTextFaceModel ( card ) {
2026-04-01 19:26:38 -07:00
const label = state . showInfo ? buildHouseLabel ( card ) : null ;
2026-04-01 16:08:52 -07:00
const displayName = normalizeLabelText ( getDisplayCardName ( card ) ) ;
if ( card ? . arcana !== "Major" && label ? . primary ) {
return {
primary : displayName || "Tarot" ,
secondary : [ label . primary , label . secondary ] . filter ( Boolean ) . join ( " · " ) ,
className : label . className || ""
} ;
}
if ( label ? . primary ) {
return {
primary : label . primary ,
secondary : label . secondary || ( displayName && label . primary !== displayName ? displayName : "" ) ,
className : label . className || ""
} ;
}
2026-04-04 03:39:29 -07:00
return {
primary : displayName || "Tarot" ,
secondary : "" ,
className : ""
} ;
}
function getCardOverlayDate ( card ) {
const court = getRelation ( card , "courtDateWindow" ) ? . data || null ;
if ( court ? . dateStart && court ? . dateEnd ) {
return formatDateRange ( court . dateStart , court . dateEnd ) ;
}
const decan = getRelation ( card , "decan" ) ? . data || null ;
if ( decan ? . dateStart && decan ? . dateEnd ) {
return formatDateRange ( decan . dateStart , decan . dateEnd ) ;
}
const zodiac = getRelation ( card , "zodiacCorrespondence" ) ? . data || null ;
const signId = normalizeKey ( zodiac ? . signId ) ;
const signStart = ZODIAC _START _TOKEN _BY _SIGN _ID [ signId ] ;
if ( signStart ) {
const signIds = Object . keys ( ZODIAC _START _TOKEN _BY _SIGN _ID ) ;
const index = signIds . indexOf ( signId ) ;
const nextSignId = signIds [ ( index + 1 ) % signIds . length ] ;
const nextStart = ZODIAC _START _TOKEN _BY _SIGN _ID [ nextSignId ] ;
const endToken = decrementToken ( nextStart ) ;
return formatDateRange ( signStart , endToken ) ;
}
return "" ;
}
function getSlotId ( row , column ) {
return ` ${ row } : ${ column } ` ;
}
function setStatus ( message ) {
state . statusMessage = String ( message || "" ) . trim ( ) ;
const { tarotFrameStatusEl } = getElements ( ) ;
if ( tarotFrameStatusEl ) {
tarotFrameStatusEl . textContent = state . statusMessage ;
}
}
function applyLayoutPreset ( layoutId = state . currentLayoutId , cards = getCards ( ) , nextStatusMessage = "" ) {
const layoutPreset = getLayoutPreset ( layoutId ) || LAYOUT _PRESETS [ 0 ] ;
state . currentLayoutId = layoutPreset . id ;
state . slotAssignments . clear ( ) ;
layoutPreset . buildPlacements ( cards ) . forEach ( ( placement ) => {
state . slotAssignments . set ( getSlotId ( placement . row , placement . column ) , placement . cardId ) ;
} ) ;
state . layoutReady = true ;
persistActiveLayoutId ( layoutPreset . id ) ;
setStatus ( nextStatusMessage || layoutPreset . statusMessage || buildReadyStatus ( cards ) ) ;
}
function applySavedLayout ( layoutId = state . currentLayoutId , cards = getCards ( ) , nextStatusMessage = "" ) {
const savedLayout = getSavedLayout ( layoutId ) ;
if ( ! savedLayout ) {
applyLayoutPreset ( "frames" , cards , nextStatusMessage ) ;
return ;
}
const cardMap = getCardMap ( cards ) ;
state . currentLayoutId = savedLayout . id ;
state . slotAssignments . clear ( ) ;
savedLayout . slotAssignments . forEach ( ( entry ) => {
if ( cardMap . has ( entry . cardId ) ) {
state . slotAssignments . set ( entry . slotId , entry . cardId ) ;
}
} ) ;
applyFrameSettingsSnapshot ( savedLayout . settings ) ;
state . layoutReady = true ;
persistActiveLayoutId ( savedLayout . id ) ;
setStatus ( nextStatusMessage || savedLayout . statusMessage || ` ${ savedLayout . label } layout applied to the master grid. ` ) ;
}
function applyLayoutSelection ( layoutId = state . currentLayoutId , cards = getCards ( ) , nextStatusMessage = "" ) {
if ( isCustomLayout ( layoutId ) ) {
applySavedLayout ( layoutId , cards , nextStatusMessage ) ;
return ;
}
applyLayoutPreset ( layoutId , cards , nextStatusMessage ) ;
}
function resetLayout ( cards = getCards ( ) , nextStatusMessage = "" ) {
applyLayoutSelection ( state . currentLayoutId , cards , nextStatusMessage ) ;
}
function buildSavedLayoutMenuDescription ( layout ) {
const zoomLabel = Number . isFinite ( Number ( layout ? . settings ? . gridZoomScale ) )
? ` ${ Math . round ( clampFrameGridZoomScale ( layout . settings . gridZoomScale ) * 100 ) } % zoom `
: ( Number . isFinite ( Number ( layout ? . settings ? . gridZoomStepIndex ) )
? ` ${ Math . round ( ( FRAME _GRID _ZOOM _STEPS [ layout . settings . gridZoomStepIndex ] || FRAME _GRID _ZOOM _STEPS [ 0 ] ) * 100 ) } % zoom `
: "saved settings" ) ;
const infoLabel = layout ? . settings ? . showInfo === false ? "info hidden" : "info visible" ;
const noteLabel = normalizeLayoutNote ( layout ? . note || state . layoutNotesById ? . [ layout ? . id ] ) ? "note added" : "no note" ;
return ` ${ layout ? . slotAssignments ? . length || 0 } saved slots · ${ zoomLabel } · ${ infoLabel } · ${ noteLabel } ` ;
}
function createLayoutOptionButton ( layout , isActive ) {
const button = document . createElement ( "button" ) ;
button . type = "button" ;
button . className = "tarot-frame-layout-option" ;
button . dataset . layoutId = layout . id ;
button . setAttribute ( "role" , "menuitemradio" ) ;
button . setAttribute ( "aria-checked" , isActive ? "true" : "false" ) ;
button . classList . toggle ( "is-active" , isActive ) ;
button . disabled = Boolean ( state . exportInProgress ) ;
const titleEl = document . createElement ( "strong" ) ;
titleEl . textContent = layout . label ;
const descriptionEl = document . createElement ( "span" ) ;
descriptionEl . textContent = layout . isCustom
? buildSavedLayoutMenuDescription ( layout )
: ( layout . id === "house"
? "The legacy house composition rebuilt inside the 14x14 snap grid."
: "The current master frame with top-row extras and nested chronological rings." ) ;
button . append ( titleEl , descriptionEl ) ;
return button ;
}
function renderLayoutPanel ( ) {
const { tarotFrameLayoutPanelEl } = getElements ( ) ;
if ( ! ( tarotFrameLayoutPanelEl instanceof HTMLElement ) ) {
return ;
}
tarotFrameLayoutPanelEl . replaceChildren ( ) ;
const saveButtonEl = document . createElement ( "button" ) ;
saveButtonEl . type = "button" ;
saveButtonEl . className = "tarot-frame-layout-save-btn" ;
saveButtonEl . dataset . layoutSaveAction = "true" ;
saveButtonEl . textContent = "Save Current Layout" ;
saveButtonEl . disabled = Boolean ( state . exportInProgress ) ;
tarotFrameLayoutPanelEl . appendChild ( saveButtonEl ) ;
const builtInHeadingEl = document . createElement ( "div" ) ;
builtInHeadingEl . className = "tarot-frame-layout-section-title" ;
builtInHeadingEl . textContent = "Built-in Layouts" ;
tarotFrameLayoutPanelEl . appendChild ( builtInHeadingEl ) ;
LAYOUT _PRESETS . forEach ( ( layout ) => {
tarotFrameLayoutPanelEl . appendChild ( createLayoutOptionButton ( layout , state . currentLayoutId === layout . id ) ) ;
} ) ;
const savedHeadingEl = document . createElement ( "div" ) ;
savedHeadingEl . className = "tarot-frame-layout-section-title" ;
savedHeadingEl . textContent = "Saved Layouts" ;
tarotFrameLayoutPanelEl . appendChild ( savedHeadingEl ) ;
if ( ! state . customLayouts . length ) {
const emptyEl = document . createElement ( "div" ) ;
emptyEl . className = "tarot-frame-layout-empty-note" ;
emptyEl . textContent = "Save a layout to keep custom card positions and frame settings in this browser." ;
tarotFrameLayoutPanelEl . appendChild ( emptyEl ) ;
return ;
}
state . customLayouts . forEach ( ( layout ) => {
const rowEl = document . createElement ( "div" ) ;
rowEl . className = "tarot-frame-layout-entry" ;
rowEl . appendChild ( createLayoutOptionButton ( layout , state . currentLayoutId === layout . id ) ) ;
const deleteButtonEl = document . createElement ( "button" ) ;
deleteButtonEl . type = "button" ;
deleteButtonEl . className = "tarot-frame-layout-delete-btn" ;
deleteButtonEl . dataset . layoutDeleteId = layout . id ;
deleteButtonEl . textContent = "Delete" ;
deleteButtonEl . disabled = Boolean ( state . exportInProgress ) ;
deleteButtonEl . setAttribute ( "aria-label" , ` Delete saved layout ${ layout . label } ` ) ;
rowEl . appendChild ( deleteButtonEl ) ;
tarotFrameLayoutPanelEl . appendChild ( rowEl ) ;
} ) ;
2026-04-01 16:08:52 -07:00
}
2026-04-04 03:39:29 -07:00
function saveCurrentLayout ( ) {
const cards = getCards ( ) ;
if ( ! cards . length ) {
setStatus ( "Tarot cards are still loading..." ) ;
return ;
2026-04-01 12:31:56 -07:00
}
2026-04-04 03:39:29 -07:00
const activeSavedLayout = getSavedLayout ( state . currentLayoutId ) ;
const suggestedName = activeSavedLayout ? . label || "" ;
const inputName = window . prompt ( "Save current Tarot Frame layout as:" , suggestedName ) ;
if ( inputName === null ) {
return ;
2026-04-01 16:08:52 -07:00
}
2026-04-04 03:39:29 -07:00
const label = normalizeLayoutLabel ( inputName ) ;
if ( ! label ) {
setStatus ( "Layout save cancelled. Enter a name to save this arrangement." ) ;
return ;
2026-04-01 12:31:56 -07:00
}
2026-04-04 03:39:29 -07:00
const existingLayout = state . customLayouts . find ( ( layout ) => normalizeKey ( layout . label ) === normalizeKey ( label ) ) || null ;
if ( existingLayout && existingLayout . id !== activeSavedLayout ? . id ) {
const shouldOverwrite = window . confirm ( ` Replace the saved layout \" ${ existingLayout . label } \" ? ` ) ;
if ( ! shouldOverwrite ) {
return ;
}
}
2026-04-01 12:31:56 -07:00
2026-04-04 03:39:29 -07:00
const savedLayout = normalizeSavedLayoutRecord ( {
id : existingLayout ? . id || activeSavedLayout ? . id || createSavedLayoutId ( ) ,
label ,
slotAssignments : captureSlotAssignmentsSnapshot ( cards ) ,
settings : buildFrameSettingsSnapshot ( ) ,
note : getLayoutNote ( state . currentLayoutId ) ,
createdAt : existingLayout ? . createdAt || activeSavedLayout ? . createdAt || new Date ( ) . toISOString ( )
} ) ;
if ( ! savedLayout ) {
setStatus ( "Unable to save this layout." ) ;
return ;
}
state . customLayouts = [ ... state . customLayouts . filter ( ( layout ) => layout . id !== savedLayout . id ) , savedLayout ]
. sort ( ( left , right ) => String ( left . label || "" ) . localeCompare ( String ( right . label || "" ) ) ) ;
state . currentLayoutId = savedLayout . id ;
setLayoutNote ( savedLayout . id , savedLayout . note , { updateUi : false } ) ;
persistSavedLayouts ( ) ;
persistActiveLayoutId ( savedLayout . id ) ;
render ( ) ;
syncControls ( ) ;
setStatus ( ` Saved layout \" ${ savedLayout . label } \" to this browser. ` ) ;
2026-04-01 12:31:56 -07:00
}
2026-04-04 03:39:29 -07:00
function deleteSavedLayout ( layoutId ) {
const savedLayout = getSavedLayout ( layoutId ) ;
if ( ! savedLayout ) {
return ;
2026-04-01 12:31:56 -07:00
}
2026-04-04 03:39:29 -07:00
const shouldDelete = window . confirm ( ` Delete the saved layout \" ${ savedLayout . label } \" from this browser? ` ) ;
if ( ! shouldDelete ) {
return ;
}
2026-04-01 12:31:56 -07:00
2026-04-04 03:39:29 -07:00
state . customLayouts = state . customLayouts . filter ( ( layout ) => layout . id !== savedLayout . id ) ;
delete state . layoutNotesById [ savedLayout . id ] ;
persistSavedLayouts ( ) ;
persistLayoutNotes ( ) ;
2026-04-01 12:31:56 -07:00
2026-04-04 03:39:29 -07:00
const cards = getCards ( ) ;
if ( state . currentLayoutId === savedLayout . id ) {
applyLayoutPreset ( "frames" , cards , ` Deleted saved layout \" ${ savedLayout . label } \" . Frames layout applied to the master grid. ` ) ;
render ( ) ;
syncControls ( ) ;
return ;
}
2026-04-01 16:08:52 -07:00
2026-04-04 03:39:29 -07:00
syncControls ( ) ;
setStatus ( ` Deleted saved layout \" ${ savedLayout . label } \" from this browser. ` ) ;
2026-04-01 12:31:56 -07:00
}
function getAssignedCard ( slotId , cardMap ) {
const cardId = String ( state . slotAssignments . get ( slotId ) || "" ) . trim ( ) ;
return cardMap . get ( cardId ) || null ;
}
function getCardOverlayLabel ( card ) {
2026-04-01 16:08:52 -07:00
if ( ! state . showInfo ) {
return "" ;
}
2026-04-01 19:26:38 -07:00
const label = buildHouseLabel ( card ) ;
const structuredLabel = normalizeLabelText ( [ label ? . primary , label ? . secondary ] . filter ( Boolean ) . join ( " · " ) ) ;
if ( structuredLabel ) {
return structuredLabel ;
2026-04-01 16:08:52 -07:00
}
2026-04-01 12:31:56 -07:00
return getCardOverlayDate ( card ) || formatMonthDay ( getRelation ( card , "decan" ) ? . data ? . dateStart ) || getDisplayCardName ( card ) ;
}
2026-04-02 01:10:50 -07:00
function getOccupiedGridBounds ( gridTrackEl ) {
if ( ! ( gridTrackEl instanceof HTMLElement ) ) {
return null ;
}
const filledSlots = Array . from ( gridTrackEl . querySelectorAll ( ".tarot-frame-slot:not(.is-empty-slot)" ) ) ;
if ( ! filledSlots . length ) {
return null ;
}
const trackRect = gridTrackEl . getBoundingClientRect ( ) ;
return filledSlots . reduce ( ( bounds , slotEl ) => {
if ( ! ( slotEl instanceof HTMLElement ) ) {
return bounds ;
}
const slotRect = slotEl . getBoundingClientRect ( ) ;
const left = slotRect . left - trackRect . left ;
const right = slotRect . right - trackRect . left ;
if ( ! bounds ) {
return { left , right } ;
}
return {
left : Math . min ( bounds . left , left ) ,
right : Math . max ( bounds . right , right )
} ;
} , null ) ;
}
function resetFrameSectionScroll ( ) {
const sectionEl = document . getElementById ( "tarot-frame-section" ) ;
if ( ! ( sectionEl instanceof HTMLElement ) ) {
return ;
}
sectionEl . scrollTop = 0 ;
}
function centerGridViewport ( attempt = 0 ) {
2026-04-01 19:26:38 -07:00
const { tarotFrameBoardEl } = getElements ( ) ;
const gridViewportEl = tarotFrameBoardEl ? . querySelector ( ".tarot-frame-grid-viewport" ) ;
const gridTrackEl = tarotFrameBoardEl ? . querySelector ( ".tarot-frame-grid-track" ) ;
if ( ! ( gridViewportEl instanceof HTMLElement ) || ! ( gridTrackEl instanceof HTMLElement ) ) {
return ;
}
requestAnimationFrame ( ( ) => {
if ( ! ( gridViewportEl instanceof HTMLElement ) || ! ( gridTrackEl instanceof HTMLElement ) ) {
return ;
}
2026-04-02 01:10:50 -07:00
const contentWidth = gridTrackEl . scrollWidth || gridTrackEl . offsetWidth ;
const viewportWidth = gridViewportEl . clientWidth ;
if ( ! contentWidth || ! viewportWidth ) {
if ( attempt < 6 ) {
centerGridViewport ( attempt + 1 ) ;
}
return ;
}
const occupiedBounds = getOccupiedGridBounds ( gridTrackEl ) ;
const targetCenter = occupiedBounds
? ( occupiedBounds . left + occupiedBounds . right ) / 2
: contentWidth / 2 ;
const maxScrollLeft = Math . max ( 0 , contentWidth - viewportWidth ) ;
const targetScrollLeft = Math . min ( Math . max ( targetCenter - ( viewportWidth / 2 ) , 0 ) , maxScrollLeft ) ;
gridViewportEl . scrollLeft = targetScrollLeft ;
2026-04-01 19:26:38 -07:00
requestAnimationFrame ( ( ) => {
if ( ! ( gridViewportEl instanceof HTMLElement ) || ! ( gridTrackEl instanceof HTMLElement ) ) {
return ;
}
2026-04-02 01:10:50 -07:00
const nextContentWidth = gridTrackEl . scrollWidth || gridTrackEl . offsetWidth ;
const nextViewportWidth = gridViewportEl . clientWidth ;
if ( ! nextContentWidth || ! nextViewportWidth ) {
if ( attempt < 6 ) {
centerGridViewport ( attempt + 1 ) ;
}
return ;
}
const nextOccupiedBounds = getOccupiedGridBounds ( gridTrackEl ) ;
const nextTargetCenter = nextOccupiedBounds
? ( nextOccupiedBounds . left + nextOccupiedBounds . right ) / 2
: nextContentWidth / 2 ;
const nextMaxScrollLeft = Math . max ( 0 , nextContentWidth - nextViewportWidth ) ;
const nextTargetScrollLeft = Math . min ( Math . max ( nextTargetCenter - ( nextViewportWidth / 2 ) , 0 ) , nextMaxScrollLeft ) ;
gridViewportEl . scrollLeft = nextTargetScrollLeft ;
2026-04-01 19:26:38 -07:00
} ) ;
} ) ;
}
2026-04-04 03:39:29 -07:00
function captureGridViewportSnapshot ( ) {
const viewportEl = getGridViewportElement ( ) ;
if ( ! ( viewportEl instanceof HTMLElement ) ) {
return null ;
}
return {
scrollLeft : viewportEl . scrollLeft ,
scrollTop : viewportEl . scrollTop
} ;
}
function captureGridViewportAnchor ( clientX , clientY , scale = getGridZoomScale ( ) ) {
const viewportEl = getGridViewportElement ( ) ;
if ( ! ( viewportEl instanceof HTMLElement ) ) {
return null ;
}
const rect = viewportEl . getBoundingClientRect ( ) ;
if ( ! ( rect . width > 0 && rect . height > 0 && scale > 0 ) ) {
return null ;
}
const offsetX = Math . min ( Math . max ( ( Number ( clientX ) || 0 ) - rect . left , 0 ) , rect . width ) ;
const offsetY = Math . min ( Math . max ( ( Number ( clientY ) || 0 ) - rect . top , 0 ) , rect . height ) ;
return {
offsetX ,
offsetY ,
contentX : ( viewportEl . scrollLeft + offsetX ) / scale ,
contentY : ( viewportEl . scrollTop + offsetY ) / scale
} ;
}
function cancelPendingGridViewportRestore ( ) {
if ( ! pendingGridViewportRestoreFrameId ) {
return ;
}
window . cancelAnimationFrame ( pendingGridViewportRestoreFrameId ) ;
pendingGridViewportRestoreFrameId = 0 ;
}
function applyClampedGridViewportScroll ( viewportEl , scrollLeft , scrollTop ) {
if ( ! ( viewportEl instanceof HTMLElement ) ) {
return ;
}
const maxScrollLeft = Math . max ( 0 , viewportEl . scrollWidth - viewportEl . clientWidth ) ;
const maxScrollTop = Math . max ( 0 , viewportEl . scrollHeight - viewportEl . clientHeight ) ;
viewportEl . scrollLeft = Math . min ( Math . max ( Number ( scrollLeft ) || 0 , 0 ) , maxScrollLeft ) ;
viewportEl . scrollTop = Math . min ( Math . max ( Number ( scrollTop ) || 0 , 0 ) , maxScrollTop ) ;
}
function restoreGridViewport ( snapshot ) {
if ( ! snapshot ) {
return ;
}
const viewportEl = getGridViewportElement ( ) ;
if ( ! ( viewportEl instanceof HTMLElement ) ) {
return ;
}
cancelPendingGridViewportRestore ( ) ;
applyClampedGridViewportScroll ( viewportEl , snapshot . scrollLeft , snapshot . scrollTop ) ;
pendingGridViewportRestoreFrameId = window . requestAnimationFrame ( ( ) => {
pendingGridViewportRestoreFrameId = 0 ;
const activeViewportEl = getGridViewportElement ( ) ;
if ( ! ( activeViewportEl instanceof HTMLElement ) ) {
return ;
}
applyClampedGridViewportScroll ( activeViewportEl , snapshot . scrollLeft , snapshot . scrollTop ) ;
} ) ;
}
function restoreGridViewportAnchor ( anchorSnapshot , scale = getGridZoomScale ( ) ) {
if ( ! anchorSnapshot ) {
return ;
}
const applyAnchor = ( ) => {
const viewportEl = getGridViewportElement ( ) ;
if ( ! ( viewportEl instanceof HTMLElement ) || ! ( scale > 0 ) ) {
return ;
}
const targetScrollLeft = ( Number ( anchorSnapshot . contentX ) * scale ) - Number ( anchorSnapshot . offsetX ) ;
const targetScrollTop = ( Number ( anchorSnapshot . contentY ) * scale ) - Number ( anchorSnapshot . offsetY ) ;
applyClampedGridViewportScroll ( viewportEl , targetScrollLeft , targetScrollTop ) ;
} ;
cancelPendingGridViewportRestore ( ) ;
applyAnchor ( ) ;
pendingGridViewportRestoreFrameId = window . requestAnimationFrame ( ( ) => {
pendingGridViewportRestoreFrameId = 0 ;
applyAnchor ( ) ;
} ) ;
}
2026-04-01 16:08:52 -07:00
function createCardTextFaceElement ( faceModel ) {
const faceEl = document . createElement ( "span" ) ;
faceEl . className = ` tarot-frame-card-text-face ${ faceModel ? . className ? ` ${ faceModel . className } ` : "" } ` ;
const primaryEl = document . createElement ( "span" ) ;
primaryEl . className = "tarot-frame-card-text-primary" ;
primaryEl . textContent = faceModel ? . primary || "Tarot" ;
faceEl . appendChild ( primaryEl ) ;
if ( faceModel ? . secondary ) {
const secondaryEl = document . createElement ( "span" ) ;
secondaryEl . className = "tarot-frame-card-text-secondary" ;
secondaryEl . textContent = faceModel . secondary ;
faceEl . appendChild ( secondaryEl ) ;
}
return faceEl ;
}
2026-04-01 12:31:56 -07:00
function createSlot ( row , column , card ) {
const slotId = getSlotId ( row , column ) ;
const slotEl = document . createElement ( "div" ) ;
slotEl . className = "tarot-frame-slot" ;
slotEl . dataset . slotId = slotId ;
slotEl . style . gridRow = String ( row ) ;
slotEl . style . gridColumn = String ( column ) ;
if ( state . drag ? . sourceSlotId === slotId ) {
slotEl . classList . add ( "is-drag-source" ) ;
}
if ( state . drag ? . hoverSlotId === slotId && state . drag ? . started ) {
slotEl . classList . add ( "is-drop-target" ) ;
}
const button = document . createElement ( "button" ) ;
button . type = "button" ;
button . className = "tarot-frame-card" ;
button . dataset . slotId = slotId ;
button . draggable = false ;
if ( ! card ) {
slotEl . classList . add ( "is-empty-slot" ) ;
button . classList . add ( "is-empty" ) ;
2026-04-04 03:39:29 -07:00
button . setAttribute ( "aria-label" , ` Empty slot at row ${ row } , column ${ column } ` ) ;
2026-04-01 12:31:56 -07:00
button . tabIndex = - 1 ;
const emptyEl = document . createElement ( "span" ) ;
emptyEl . className = "tarot-frame-slot-empty" ;
button . appendChild ( emptyEl ) ;
slotEl . appendChild ( button ) ;
return slotEl ;
}
button . dataset . cardId = getCardId ( card ) ;
button . setAttribute ( "aria-label" , ` ${ getDisplayCardName ( card ) } in row ${ row } , column ${ column } ` ) ;
button . title = getDisplayCardName ( card ) ;
2026-04-01 16:08:52 -07:00
const showImage = shouldShowCardImage ( card ) ;
2026-04-01 12:31:56 -07:00
const imageSrc = resolveCardThumbnail ( card ) ;
2026-04-01 16:08:52 -07:00
if ( showImage && imageSrc ) {
2026-04-01 12:31:56 -07:00
const image = document . createElement ( "img" ) ;
image . className = "tarot-frame-card-image" ;
image . src = imageSrc ;
image . alt = getDisplayCardName ( card ) ;
image . loading = "lazy" ;
image . decoding = "async" ;
image . draggable = false ;
button . appendChild ( image ) ;
2026-04-01 16:08:52 -07:00
} else if ( showImage ) {
2026-04-01 12:31:56 -07:00
const fallback = document . createElement ( "span" ) ;
fallback . className = "tarot-frame-card-fallback" ;
fallback . textContent = getDisplayCardName ( card ) ;
button . appendChild ( fallback ) ;
2026-04-01 16:08:52 -07:00
} else {
button . appendChild ( createCardTextFaceElement ( buildCardTextFaceModel ( card ) ) ) ;
2026-04-01 12:31:56 -07:00
}
2026-04-01 16:08:52 -07:00
if ( showImage && state . showInfo ) {
2026-04-01 12:31:56 -07:00
const overlay = document . createElement ( "span" ) ;
overlay . className = "tarot-frame-card-badge" ;
overlay . textContent = getCardOverlayLabel ( card ) ;
button . appendChild ( overlay ) ;
}
slotEl . appendChild ( button ) ;
return slotEl ;
}
2026-04-01 16:08:52 -07:00
function createLegend ( layoutPreset ) {
2026-04-01 12:31:56 -07:00
const legendEl = document . createElement ( "div" ) ;
legendEl . className = "tarot-frame-legend" ;
2026-04-01 16:08:52 -07:00
layoutPreset . legendItems . forEach ( ( layout ) => {
2026-04-01 12:31:56 -07:00
const itemEl = document . createElement ( "div" ) ;
itemEl . className = "tarot-frame-legend-item" ;
const titleEl = document . createElement ( "strong" ) ;
titleEl . textContent = layout . title ;
const textEl = document . createElement ( "span" ) ;
textEl . textContent = layout . description ;
itemEl . append ( titleEl , textEl ) ;
legendEl . appendChild ( itemEl ) ;
} ) ;
return legendEl ;
}
2026-04-04 03:39:29 -07:00
function createOverview ( layoutPreset , cards = getCards ( ) ) {
const overviewEl = document . createElement ( "section" ) ;
overviewEl . className = "tarot-frame-overview" ;
2026-04-01 12:31:56 -07:00
2026-04-04 03:39:29 -07:00
const summaryEl = document . createElement ( "div" ) ;
summaryEl . className = "tarot-frame-overview-summary" ;
2026-04-01 12:31:56 -07:00
const headEl = document . createElement ( "div" ) ;
2026-04-04 03:39:29 -07:00
headEl . className = "tarot-frame-overview-head" ;
2026-04-01 12:31:56 -07:00
const titleWrapEl = document . createElement ( "div" ) ;
2026-04-04 03:39:29 -07:00
const eyebrowEl = document . createElement ( "div" ) ;
eyebrowEl . className = "tarot-frame-overview-eyebrow" ;
eyebrowEl . textContent = layoutPreset ? . isCustom ? "Saved Layout" : "Layout Guide" ;
2026-04-01 12:31:56 -07:00
const titleEl = document . createElement ( "h3" ) ;
titleEl . className = "tarot-frame-panel-title" ;
2026-04-01 16:08:52 -07:00
titleEl . textContent = layoutPreset . title ;
2026-04-01 12:31:56 -07:00
const subtitleEl = document . createElement ( "p" ) ;
subtitleEl . className = "tarot-frame-panel-subtitle" ;
2026-04-01 16:08:52 -07:00
subtitleEl . textContent = layoutPreset . subtitle ;
2026-04-04 03:39:29 -07:00
titleWrapEl . append ( eyebrowEl , titleEl , subtitleEl ) ;
2026-04-01 12:31:56 -07:00
const countEl = document . createElement ( "span" ) ;
countEl . className = "tarot-frame-panel-count" ;
2026-04-01 19:26:38 -07:00
countEl . textContent = buildPanelCountText ( cards ) ;
2026-04-01 12:31:56 -07:00
headEl . append ( titleWrapEl , countEl ) ;
2026-04-04 03:39:29 -07:00
summaryEl . appendChild ( headEl ) ;
if ( Array . isArray ( layoutPreset . legendItems ) && layoutPreset . legendItems . length ) {
summaryEl . appendChild ( createLegend ( layoutPreset ) ) ;
}
const notesEl = document . createElement ( "section" ) ;
notesEl . className = "tarot-frame-notes-card" ;
const notesHeadEl = document . createElement ( "div" ) ;
notesHeadEl . className = "tarot-frame-notes-head" ;
const notesTitleWrapEl = document . createElement ( "div" ) ;
const notesTitleEl = document . createElement ( "h4" ) ;
notesTitleEl . className = "tarot-frame-notes-title" ;
notesTitleEl . textContent = "Layout Notes" ;
const notesCopyEl = document . createElement ( "p" ) ;
notesCopyEl . className = "tarot-frame-notes-copy" ;
notesCopyEl . textContent = "Saved automatically in this browser for the current layout." ;
notesTitleWrapEl . append ( notesTitleEl , notesCopyEl ) ;
const notesBadgeEl = document . createElement ( "span" ) ;
notesBadgeEl . className = "tarot-frame-notes-badge" ;
notesBadgeEl . textContent = getLayoutNote ( ) ? "Saved" : "Optional" ;
notesHeadEl . append ( notesTitleWrapEl , notesBadgeEl ) ;
const noteFieldEl = document . createElement ( "label" ) ;
noteFieldEl . className = "tarot-frame-notes-field" ;
const noteLabelEl = document . createElement ( "span" ) ;
noteLabelEl . textContent = "Custom text / notes" ;
const noteInputEl = document . createElement ( "textarea" ) ;
noteInputEl . id = "tarot-frame-layout-note" ;
noteInputEl . rows = 7 ;
noteInputEl . maxLength = 1600 ;
noteInputEl . placeholder = getLayoutNotePlaceholder ( layoutPreset ) ;
noteInputEl . value = getLayoutNote ( ) ;
noteInputEl . disabled = Boolean ( state . exportInProgress ) ;
noteFieldEl . append ( noteLabelEl , noteInputEl ) ;
const notesFooterEl = document . createElement ( "div" ) ;
notesFooterEl . className = "tarot-frame-notes-footer" ;
const notesHintEl = document . createElement ( "span" ) ;
notesHintEl . textContent = layoutPreset ? . isCustom
? "This note stays with the saved layout and reopens with it."
: "Use this area for placement reminders, timing, or custom reading instructions." ;
const clearButtonEl = document . createElement ( "button" ) ;
clearButtonEl . type = "button" ;
clearButtonEl . className = "tarot-frame-notes-clear" ;
clearButtonEl . dataset . frameNoteClear = "true" ;
clearButtonEl . textContent = "Clear Note" ;
clearButtonEl . disabled = ! getLayoutNote ( ) || Boolean ( state . exportInProgress ) ;
notesFooterEl . append ( notesHintEl , clearButtonEl ) ;
notesEl . append ( notesHeadEl , noteFieldEl , notesFooterEl ) ;
overviewEl . append ( summaryEl , notesEl ) ;
return overviewEl ;
}
function render ( options = { } ) {
const { tarotFrameBoardEl , tarotFrameOverviewEl } = getElements ( ) ;
if ( ! tarotFrameBoardEl || ! tarotFrameOverviewEl ) {
return ;
}
const preserveViewport = options . preserveViewport === true ;
const viewportSnapshot = preserveViewport ? captureGridViewportSnapshot ( ) : null ;
2026-04-01 12:31:56 -07:00
2026-04-04 03:39:29 -07:00
const cards = getCards ( ) ;
const cardMap = getCardMap ( cards ) ;
const layoutPreset = getLayoutDefinition ( ) ;
tarotFrameOverviewEl . replaceChildren ( ) ;
tarotFrameBoardEl . replaceChildren ( ) ;
tarotFrameOverviewEl . appendChild ( createOverview ( layoutPreset , cards ) ) ;
const panelEl = document . createElement ( "section" ) ;
panelEl . className = "tarot-frame-panel tarot-frame-panel--master" ;
panelEl . style . setProperty ( "--frame-grid-zoom-scale" , String ( getGridZoomScale ( ) ) ) ;
2026-04-01 12:31:56 -07:00
2026-04-01 19:26:38 -07:00
const gridViewportEl = document . createElement ( "div" ) ;
gridViewportEl . className = "tarot-frame-grid-viewport" ;
const gridTrackEl = document . createElement ( "div" ) ;
gridTrackEl . className = "tarot-frame-grid-track" ;
2026-04-01 12:31:56 -07:00
const gridEl = document . createElement ( "div" ) ;
gridEl . className = "tarot-frame-grid tarot-frame-grid--master" ;
gridEl . classList . toggle ( "is-info-hidden" , ! state . showInfo ) ;
gridEl . style . setProperty ( "--frame-grid-size" , String ( MASTER _GRID _SIZE ) ) ;
for ( let row = 1 ; row <= MASTER _GRID _SIZE ; row += 1 ) {
for ( let column = 1 ; column <= MASTER _GRID _SIZE ; column += 1 ) {
gridEl . appendChild ( createSlot ( row , column , getAssignedCard ( getSlotId ( row , column ) , cardMap ) ) ) ;
}
}
2026-04-01 19:26:38 -07:00
gridTrackEl . appendChild ( gridEl ) ;
gridViewportEl . appendChild ( gridTrackEl ) ;
panelEl . appendChild ( gridViewportEl ) ;
2026-04-01 12:31:56 -07:00
tarotFrameBoardEl . appendChild ( panelEl ) ;
2026-04-04 03:39:29 -07:00
updateViewportInteractionState ( ) ;
if ( preserveViewport ) {
restoreGridViewport ( viewportSnapshot ) ;
return ;
}
2026-04-01 19:26:38 -07:00
centerGridViewport ( ) ;
}
2026-04-04 03:39:29 -07:00
function applyGridZoomState ( options = { } ) {
const { tarotFrameBoardEl , tarotFrameOverviewEl } = getElements ( ) ;
2026-04-01 19:26:38 -07:00
const panelEl = tarotFrameBoardEl ? . querySelector ( ".tarot-frame-panel--master" ) ;
if ( ! ( panelEl instanceof HTMLElement ) ) {
return ;
}
2026-04-04 03:39:29 -07:00
const anchorSnapshot = options . anchorSnapshot || null ;
const preserveViewport = options . preserveViewport !== false ;
const viewportSnapshot = preserveViewport ? captureGridViewportSnapshot ( ) : null ;
2026-04-01 19:26:38 -07:00
panelEl . style . setProperty ( "--frame-grid-zoom-scale" , String ( getGridZoomScale ( ) ) ) ;
2026-04-04 03:39:29 -07:00
const countEl = tarotFrameOverviewEl ? . querySelector ( ".tarot-frame-panel-count" ) ;
2026-04-01 19:26:38 -07:00
if ( countEl instanceof HTMLElement ) {
countEl . textContent = buildPanelCountText ( ) ;
}
2026-04-04 03:39:29 -07:00
if ( anchorSnapshot ) {
restoreGridViewportAnchor ( anchorSnapshot , getGridZoomScale ( ) ) ;
return ;
}
if ( preserveViewport ) {
restoreGridViewport ( viewportSnapshot ) ;
return ;
}
2026-04-01 19:26:38 -07:00
centerGridViewport ( ) ;
}
2026-04-04 03:39:29 -07:00
function setGridZoomScale ( nextScale , options = { } ) {
const anchorSnapshot = options . anchorClientX === undefined || options . anchorClientY === undefined
? null
: captureGridViewportAnchor ( options . anchorClientX , options . anchorClientY , getGridZoomScale ( ) ) ;
const safeScale = clampFrameGridZoomScale ( nextScale ) ;
state . gridZoomScale = safeScale ;
state . gridZoomStepIndex = getNearestFrameZoomStepIndex ( safeScale ) ;
applyGridZoomState ( {
preserveViewport : options . preserveViewport !== false ,
anchorSnapshot
} ) ;
if ( options . statusMessage !== "" ) {
setStatus ( options . statusMessage || ` Frame grid zoom ${ Math . round ( getGridZoomScale ( ) * 100 ) } %. This setting applies to every Frame layout. ` ) ;
}
}
2026-04-01 19:26:38 -07:00
function setGridZoomStepIndex ( nextIndex ) {
const safeIndex = Math . max ( 0 , Math . min ( FRAME _GRID _ZOOM _STEPS . length - 1 , Number ( nextIndex ) || 0 ) ) ;
state . gridZoomStepIndex = safeIndex ;
2026-04-04 03:39:29 -07:00
state . gridZoomScale = FRAME _GRID _ZOOM _STEPS [ safeIndex ] || FRAME _GRID _ZOOM _STEPS [ 0 ] ;
applyGridZoomState ( { preserveViewport : true } ) ;
2026-04-01 19:26:38 -07:00
setStatus ( ` Frame grid zoom ${ Math . round ( getGridZoomScale ( ) * 100 ) } %. This setting applies to every Frame layout. ` ) ;
2026-04-01 12:31:56 -07:00
}
function syncControls ( ) {
const {
2026-04-04 03:39:29 -07:00
tarotFramePanToggleEl ,
tarotFrameFocusToggleEl ,
tarotFrameFocusExitEl ,
2026-04-01 16:08:52 -07:00
tarotFrameLayoutToggleEl ,
tarotFrameLayoutPanelEl ,
2026-04-01 12:31:56 -07:00
tarotFrameSettingsToggleEl ,
tarotFrameSettingsPanelEl ,
2026-04-01 19:26:38 -07:00
tarotFrameGridZoomEl ,
2026-04-01 12:31:56 -07:00
tarotFrameShowInfoEl ,
2026-04-01 16:08:52 -07:00
tarotFrameHouseSettingsEl ,
tarotFrameHouseTopCardsVisibleEl ,
tarotFrameHouseTopInfoHebrewEl ,
tarotFrameHouseTopInfoPlanetEl ,
tarotFrameHouseTopInfoZodiacEl ,
tarotFrameHouseTopInfoTrumpEl ,
tarotFrameHouseTopInfoPathEl ,
tarotFrameHouseTopInfoDateEl ,
tarotFrameHouseBottomCardsVisibleEl ,
tarotFrameHouseBottomInfoZodiacEl ,
tarotFrameHouseBottomInfoDecanEl ,
tarotFrameHouseBottomInfoMonthEl ,
tarotFrameHouseBottomInfoRulerEl ,
tarotFrameHouseBottomInfoDateEl ,
2026-04-04 03:39:29 -07:00
tarotFrameClearGridEl ,
2026-04-01 12:31:56 -07:00
tarotFrameExportWebpEl ,
} = getElements ( ) ;
2026-04-04 03:39:29 -07:00
const activeLayout = getLayoutDefinition ( ) ;
if ( tarotFramePanToggleEl ) {
tarotFramePanToggleEl . setAttribute ( "aria-pressed" , state . panMode ? "true" : "false" ) ;
tarotFramePanToggleEl . classList . toggle ( "is-active" , state . panMode ) ;
tarotFramePanToggleEl . textContent = state . panMode ? "Panning" : "Pan Grid" ;
tarotFramePanToggleEl . disabled = Boolean ( state . exportInProgress ) ;
}
if ( tarotFrameFocusToggleEl || tarotFrameFocusExitEl ) {
applyGridFocusModeUi ( ) ;
}
2026-04-01 16:08:52 -07:00
if ( tarotFrameLayoutToggleEl ) {
tarotFrameLayoutToggleEl . setAttribute ( "aria-expanded" , state . layoutMenuOpen ? "true" : "false" ) ;
2026-04-04 03:39:29 -07:00
tarotFrameLayoutToggleEl . textContent = ` Layout: ${ activeLayout . label } ` ;
2026-04-01 16:08:52 -07:00
tarotFrameLayoutToggleEl . disabled = Boolean ( state . exportInProgress ) ;
}
if ( tarotFrameLayoutPanelEl ) {
tarotFrameLayoutPanelEl . hidden = ! state . layoutMenuOpen ;
2026-04-04 03:39:29 -07:00
renderLayoutPanel ( ) ;
2026-04-01 16:08:52 -07:00
}
2026-04-01 12:31:56 -07:00
if ( tarotFrameSettingsToggleEl ) {
tarotFrameSettingsToggleEl . setAttribute ( "aria-expanded" , state . settingsOpen ? "true" : "false" ) ;
tarotFrameSettingsToggleEl . textContent = state . settingsOpen ? "Hide Settings" : "Settings" ;
tarotFrameSettingsToggleEl . disabled = Boolean ( state . exportInProgress ) ;
}
if ( tarotFrameSettingsPanelEl ) {
tarotFrameSettingsPanelEl . hidden = ! state . settingsOpen ;
}
2026-04-01 19:26:38 -07:00
if ( tarotFrameGridZoomEl ) {
tarotFrameGridZoomEl . value = String ( state . gridZoomStepIndex ) ;
tarotFrameGridZoomEl . disabled = Boolean ( state . exportInProgress ) ;
}
2026-04-01 12:31:56 -07:00
if ( tarotFrameShowInfoEl ) {
tarotFrameShowInfoEl . checked = Boolean ( state . showInfo ) ;
tarotFrameShowInfoEl . disabled = Boolean ( state . exportInProgress ) ;
}
2026-04-01 16:08:52 -07:00
if ( tarotFrameHouseSettingsEl ) {
2026-04-01 19:26:38 -07:00
tarotFrameHouseSettingsEl . hidden = false ;
2026-04-01 16:08:52 -07:00
}
if ( tarotFrameHouseTopCardsVisibleEl ) {
tarotFrameHouseTopCardsVisibleEl . checked = config . getHouseTopCardsVisible ? . ( ) !== false ;
2026-04-01 19:26:38 -07:00
tarotFrameHouseTopCardsVisibleEl . disabled = Boolean ( state . exportInProgress ) ;
2026-04-01 16:08:52 -07:00
}
[
[ tarotFrameHouseTopInfoHebrewEl , "hebrew" , config . getHouseTopInfoModes ] ,
[ tarotFrameHouseTopInfoPlanetEl , "planet" , config . getHouseTopInfoModes ] ,
[ tarotFrameHouseTopInfoZodiacEl , "zodiac" , config . getHouseTopInfoModes ] ,
[ tarotFrameHouseTopInfoTrumpEl , "trump" , config . getHouseTopInfoModes ] ,
[ tarotFrameHouseTopInfoPathEl , "path" , config . getHouseTopInfoModes ] ,
[ tarotFrameHouseTopInfoDateEl , "date" , config . getHouseTopInfoModes ] ,
[ tarotFrameHouseBottomInfoZodiacEl , "zodiac" , config . getHouseBottomInfoModes ] ,
[ tarotFrameHouseBottomInfoDecanEl , "decan" , config . getHouseBottomInfoModes ] ,
[ tarotFrameHouseBottomInfoMonthEl , "month" , config . getHouseBottomInfoModes ] ,
[ tarotFrameHouseBottomInfoRulerEl , "ruler" , config . getHouseBottomInfoModes ] ,
[ tarotFrameHouseBottomInfoDateEl , "date" , config . getHouseBottomInfoModes ]
] . forEach ( ( [ checkbox , mode , getter ] ) => {
if ( ! checkbox ) {
return ;
}
checkbox . checked = Boolean ( getter ? . ( ) ? . [ mode ] ) ;
2026-04-01 19:26:38 -07:00
checkbox . disabled = Boolean ( state . exportInProgress ) ;
2026-04-01 16:08:52 -07:00
} ) ;
if ( tarotFrameHouseBottomCardsVisibleEl ) {
tarotFrameHouseBottomCardsVisibleEl . checked = config . getHouseBottomCardsVisible ? . ( ) !== false ;
2026-04-01 19:26:38 -07:00
tarotFrameHouseBottomCardsVisibleEl . disabled = Boolean ( state . exportInProgress ) ;
2026-04-01 16:08:52 -07:00
}
2026-04-04 03:39:29 -07:00
if ( tarotFrameClearGridEl ) {
tarotFrameClearGridEl . disabled = Boolean ( state . exportInProgress ) ;
}
2026-04-01 12:31:56 -07:00
if ( tarotFrameExportWebpEl ) {
const supportsWebp = isExportFormatSupported ( "webp" ) ;
tarotFrameExportWebpEl . hidden = ! supportsWebp ;
tarotFrameExportWebpEl . disabled = Boolean ( state . exportInProgress ) || ! supportsWebp ;
tarotFrameExportWebpEl . textContent = state . exportInProgress ? "Exporting..." : "Export WebP" ;
if ( supportsWebp ) {
tarotFrameExportWebpEl . title = "Download the current frame grid arrangement as a WebP image." ;
}
}
2026-04-04 03:39:29 -07:00
updateLayoutNotesUi ( ) ;
2026-04-01 12:31:56 -07:00
}
function getSlotElement ( slotId ) {
return document . querySelector ( ` .tarot-frame-slot[data-slot-id=" ${ slotId } "] ` ) ;
}
function setHoverSlot ( slotId ) {
const previous = state . drag ? . hoverSlotId ;
if ( previous && previous !== slotId ) {
getSlotElement ( previous ) ? . classList . remove ( "is-drop-target" ) ;
}
if ( state . drag ) {
state . drag . hoverSlotId = slotId || "" ;
}
if ( slotId ) {
getSlotElement ( slotId ) ? . classList . add ( "is-drop-target" ) ;
}
}
function createDragGhost ( card ) {
const ghost = document . createElement ( "div" ) ;
ghost . className = "tarot-frame-drag-ghost" ;
const imageSrc = resolveCardThumbnail ( card ) ;
if ( imageSrc ) {
const image = document . createElement ( "img" ) ;
image . src = imageSrc ;
image . alt = "" ;
ghost . appendChild ( image ) ;
}
if ( state . showInfo ) {
const label = document . createElement ( "span" ) ;
label . className = "tarot-frame-drag-ghost-label" ;
label . textContent = getCardOverlayLabel ( card ) ;
ghost . appendChild ( label ) ;
}
document . body . appendChild ( ghost ) ;
return ghost ;
}
function moveGhost ( ghostEl , clientX , clientY ) {
if ( ! ( ghostEl instanceof HTMLElement ) ) {
return ;
}
ghostEl . style . left = ` ${ clientX } px ` ;
ghostEl . style . top = ` ${ clientY } px ` ;
}
function updateHoverSlotFromPoint ( clientX , clientY , sourceSlotId ) {
const target = document . elementFromPoint ( clientX , clientY ) ;
const slot = target instanceof Element ? target . closest ( ".tarot-frame-slot[data-slot-id]" ) : null ;
const nextSlotId = slot instanceof HTMLElement ? String ( slot . dataset . slotId || "" ) : "" ;
setHoverSlot ( nextSlotId && nextSlotId !== sourceSlotId ? nextSlotId : "" ) ;
}
2026-04-04 12:15:52 -07:00
function removeOrphanedDragGhosts ( ) {
document . querySelectorAll ( ".tarot-frame-drag-ghost" ) . forEach ( ( ghostEl ) => {
if ( ghostEl instanceof HTMLElement ) {
ghostEl . remove ( ) ;
}
} ) ;
document . body . classList . remove ( "is-tarot-frame-dragging" ) ;
}
2026-04-01 12:31:56 -07:00
function detachPointerListeners ( ) {
document . removeEventListener ( "pointermove" , handlePointerMove ) ;
document . removeEventListener ( "pointerup" , handlePointerUp ) ;
document . removeEventListener ( "pointercancel" , handlePointerCancel ) ;
}
function cleanupDrag ( ) {
if ( ! state . drag ) {
2026-04-04 12:15:52 -07:00
removeOrphanedDragGhosts ( ) ;
2026-04-01 12:31:56 -07:00
return ;
}
2026-04-02 22:06:19 -07:00
if ( state . drag . sourceButton instanceof HTMLElement && typeof state . drag . sourceButton . releasePointerCapture === "function" ) {
try {
if ( state . drag . sourceButton . hasPointerCapture ? . ( state . drag . pointerId ) ) {
state . drag . sourceButton . releasePointerCapture ( state . drag . pointerId ) ;
}
} catch ( _error ) {
// Ignore pointer-capture release failures during cleanup.
}
}
2026-04-01 12:31:56 -07:00
setHoverSlot ( "" ) ;
getSlotElement ( state . drag . sourceSlotId ) ? . classList . remove ( "is-drag-source" ) ;
if ( state . drag . ghostEl instanceof HTMLElement ) {
state . drag . ghostEl . remove ( ) ;
}
state . drag = null ;
2026-04-04 12:15:52 -07:00
removeOrphanedDragGhosts ( ) ;
2026-04-01 12:31:56 -07:00
detachPointerListeners ( ) ;
}
2026-04-04 12:15:52 -07:00
function handleDocumentTouchStart ( event ) {
if ( Number ( event . touches ? . length || 0 ) < 2 ) {
return ;
}
clearLongPressGesture ( ) ;
if ( state . drag ? . pointerType === "touch" ) {
cleanupDrag ( ) ;
state . suppressClick = true ;
return ;
}
removeOrphanedDragGhosts ( ) ;
}
2026-04-01 12:31:56 -07:00
function swapOrMoveSlots ( sourceSlotId , targetSlotId ) {
const sourceCardId = String ( state . slotAssignments . get ( sourceSlotId ) || "" ) ;
const targetCardId = String ( state . slotAssignments . get ( targetSlotId ) || "" ) ;
state . slotAssignments . set ( targetSlotId , sourceCardId ) ;
if ( targetCardId ) {
state . slotAssignments . set ( sourceSlotId , targetCardId ) ;
} else {
state . slotAssignments . delete ( sourceSlotId ) ;
}
}
function describeSlot ( slotId ) {
const [ rowText , columnText ] = String ( slotId || "" ) . split ( ":" ) ;
return ` row ${ rowText || "?" } , column ${ columnText || "?" } ` ;
}
function openCardLightbox ( cardId ) {
const card = getCardMap ( getCards ( ) ) . get ( String ( cardId || "" ) . trim ( ) ) || null ;
if ( ! card ) {
return ;
}
2026-04-02 22:06:19 -07:00
if ( typeof config . openCardLightbox === "function" ) {
config . openCardLightbox ( getCardId ( card ) , {
onSelectCardId : ( ) => { }
} ) ;
return ;
}
2026-04-01 12:31:56 -07:00
const deckOptions = resolveDeckOptions ( card ) ;
const src = String (
tarotCardImages . resolveTarotCardImage ? . ( card . name , deckOptions )
|| tarotCardImages . resolveTarotCardThumbnail ? . ( card . name , deckOptions )
|| ""
) . trim ( ) ;
if ( ! src ) {
return ;
}
const label = getDisplayCardName ( card ) ;
window . TarotUiLightbox ? . open ? . ( {
src ,
altText : label ,
label ,
cardId : getCardId ( card ) ,
deckId : String ( tarotCardImages . getActiveDeck ? . ( ) || "" ) . trim ( )
} ) ;
}
function handlePointerDown ( event ) {
const target = event . target ;
2026-04-04 03:39:29 -07:00
if ( ! ( target instanceof Element ) ) {
return ;
}
2026-04-04 12:15:52 -07:00
blurLayoutNoteForBoardInteraction ( target ) ;
2026-04-04 03:39:29 -07:00
if ( event . button === 1 ) {
startPanGesture ( event , { source : "pointer" } ) ;
return ;
}
if ( event . button !== 0 ) {
return ;
}
if ( state . panMode ) {
startPanGesture ( event ) ;
2026-04-01 12:31:56 -07:00
return ;
}
const cardButton = target . closest ( ".tarot-frame-card[data-slot-id][data-card-id]" ) ;
if ( ! ( cardButton instanceof HTMLButtonElement ) ) {
2026-04-04 03:39:29 -07:00
const emptyButton = target . closest ( ".tarot-frame-card.is-empty[data-slot-id]" ) ;
if ( emptyButton instanceof HTMLButtonElement && ( event . pointerType === "touch" || event . pointerType === "pen" ) ) {
event . preventDefault ( ) ;
scheduleLongPress ( String ( emptyButton . dataset . slotId || "" ) , event ) ;
}
2026-04-01 12:31:56 -07:00
return ;
}
state . drag = {
pointerId : event . pointerId ,
2026-04-04 12:15:52 -07:00
pointerType : String ( event . pointerType || "" ) . toLowerCase ( ) ,
2026-04-01 12:31:56 -07:00
sourceSlotId : String ( cardButton . dataset . slotId || "" ) ,
cardId : String ( cardButton . dataset . cardId || "" ) ,
startX : event . clientX ,
startY : event . clientY ,
2026-04-04 12:15:52 -07:00
touchEligibleAt : String ( event . pointerType || "" ) . toLowerCase ( ) === "touch"
? ( Number ( event . timeStamp ) || window . performance . now ( ) ) + FRAME _TOUCH _DRAG _ACTIVATION _DELAY _MS
: 0 ,
2026-04-01 12:31:56 -07:00
started : false ,
hoverSlotId : "" ,
2026-04-02 22:06:19 -07:00
ghostEl : null ,
sourceButton : cardButton
2026-04-01 12:31:56 -07:00
} ;
2026-04-02 22:06:19 -07:00
if ( typeof cardButton . setPointerCapture === "function" ) {
try {
cardButton . setPointerCapture ( event . pointerId ) ;
} catch ( _error ) {
// Ignore pointer-capture failures and continue with document listeners.
}
}
if ( String ( event . pointerType || "" ) . toLowerCase ( ) === "touch" ) {
event . preventDefault ( ) ;
}
2026-04-01 12:31:56 -07:00
detachPointerListeners ( ) ;
document . addEventListener ( "pointermove" , handlePointerMove ) ;
document . addEventListener ( "pointerup" , handlePointerUp ) ;
document . addEventListener ( "pointercancel" , handlePointerCancel ) ;
}
function handlePointerMove ( event ) {
2026-04-04 03:39:29 -07:00
updateLongPress ( event ) ;
2026-04-01 12:31:56 -07:00
if ( ! state . drag || event . pointerId !== state . drag . pointerId ) {
return ;
}
2026-04-04 12:15:52 -07:00
if ( state . drag . pointerType === "touch" && ( state . pinchGesture || ( state . panGesture && state . panGesture . source === "touch" ) ) ) {
cleanupDrag ( ) ;
state . suppressClick = true ;
return ;
}
if ( ! state . drag . started
&& state . drag . pointerType === "touch"
&& ( Number ( event . timeStamp ) || window . performance . now ( ) ) < Number ( state . drag . touchEligibleAt || 0 ) ) {
return ;
}
2026-04-01 12:31:56 -07:00
const movedEnough = Math . hypot ( event . clientX - state . drag . startX , event . clientY - state . drag . startY ) >= 6 ;
if ( ! state . drag . started && movedEnough ) {
const card = getCardMap ( getCards ( ) ) . get ( state . drag . cardId ) || null ;
if ( ! card ) {
cleanupDrag ( ) ;
return ;
}
state . drag . started = true ;
state . drag . ghostEl = createDragGhost ( card ) ;
getSlotElement ( state . drag . sourceSlotId ) ? . classList . add ( "is-drag-source" ) ;
document . body . classList . add ( "is-tarot-frame-dragging" ) ;
state . suppressClick = true ;
}
if ( ! state . drag . started ) {
return ;
}
moveGhost ( state . drag . ghostEl , event . clientX , event . clientY ) ;
updateHoverSlotFromPoint ( event . clientX , event . clientY , state . drag . sourceSlotId ) ;
event . preventDefault ( ) ;
}
function finishDrop ( ) {
if ( ! state . drag ) {
return ;
}
const sourceSlotId = state . drag . sourceSlotId ;
const targetSlotId = state . drag . hoverSlotId ;
const draggedCard = getCardMap ( getCards ( ) ) . get ( state . drag . cardId ) || null ;
const moved = Boolean ( targetSlotId && targetSlotId !== sourceSlotId ) ;
if ( moved ) {
swapOrMoveSlots ( sourceSlotId , targetSlotId ) ;
2026-04-04 03:39:29 -07:00
render ( { preserveViewport : true } ) ;
2026-04-01 12:31:56 -07:00
setStatus ( ` ${ getDisplayCardName ( draggedCard ) } snapped to ${ describeSlot ( targetSlotId ) } . ` ) ;
}
cleanupDrag ( ) ;
if ( ! moved ) {
state . suppressClick = false ;
}
}
function handlePointerUp ( event ) {
2026-04-04 03:39:29 -07:00
finishLongPress ( event ) ;
2026-04-01 12:31:56 -07:00
if ( ! state . drag || event . pointerId !== state . drag . pointerId ) {
return ;
}
if ( ! state . drag . started ) {
cleanupDrag ( ) ;
return ;
}
finishDrop ( ) ;
}
function handlePointerCancel ( event ) {
2026-04-04 03:39:29 -07:00
finishLongPress ( event ) ;
2026-04-01 12:31:56 -07:00
if ( ! state . drag || event . pointerId !== state . drag . pointerId ) {
return ;
}
cleanupDrag ( ) ;
state . suppressClick = false ;
}
function handleBoardClick ( event ) {
2026-04-04 03:39:29 -07:00
if ( state . panMode ) {
state . suppressClick = false ;
return ;
}
2026-04-01 12:31:56 -07:00
const target = event . target ;
if ( ! ( target instanceof Element ) ) {
return ;
}
const cardButton = target . closest ( ".tarot-frame-card[data-card-id]" ) ;
if ( ! ( cardButton instanceof HTMLButtonElement ) ) {
return ;
}
if ( state . suppressClick ) {
state . suppressClick = false ;
return ;
}
openCardLightbox ( cardButton . dataset . cardId ) ;
}
function handleNativeDragStart ( event ) {
const target = event . target ;
if ( ! ( target instanceof Element ) ) {
return ;
}
if ( target . closest ( ".tarot-frame-card" ) ) {
event . preventDefault ( ) ;
}
}
2026-04-04 03:39:29 -07:00
function handleBoardContextMenu ( event ) {
const target = event . target ;
if ( ! ( target instanceof Element ) ) {
return ;
}
const emptyButton = target . closest ( ".tarot-frame-card.is-empty[data-slot-id]" ) ;
if ( ! ( emptyButton instanceof HTMLButtonElement ) ) {
return ;
}
event . preventDefault ( ) ;
event . stopPropagation ( ) ;
openCardPicker ( String ( emptyButton . dataset . slotId || "" ) , event . clientX , event . clientY ) ;
}
2026-04-01 12:31:56 -07:00
function handleDocumentClick ( event ) {
const target = event . target ;
if ( ! ( target instanceof Node ) ) {
return ;
}
2026-04-01 16:08:52 -07:00
const {
2026-04-04 03:39:29 -07:00
tarotFrameSectionEl ,
tarotFrameBoardEl ,
tarotFrameFocusToggleEl ,
tarotFrameFocusExitEl ,
2026-04-01 16:08:52 -07:00
tarotFrameSettingsPanelEl ,
tarotFrameSettingsToggleEl ,
tarotFrameLayoutPanelEl ,
tarotFrameLayoutToggleEl
} = getElements ( ) ;
2026-04-04 03:39:29 -07:00
if ( state . gridFocusMode && tarotFrameSectionEl ? . contains ( target ) ) {
const targetElement = target instanceof Element ? target : null ;
const clickedInsideBoard = Boolean ( targetElement ? . closest ( "#tarot-frame-board" ) ) ;
const clickedOnFocusControl = Boolean (
tarotFrameFocusToggleEl ? . contains ( target )
|| tarotFrameFocusExitEl ? . contains ( target )
) ;
if ( ! clickedInsideBoard && ! clickedOnFocusControl ) {
setGridFocusMode ( false ) ;
return ;
}
}
2026-04-01 16:08:52 -07:00
let changed = false ;
if ( state . settingsOpen && ! tarotFrameSettingsPanelEl ? . contains ( target ) && ! tarotFrameSettingsToggleEl ? . contains ( target ) ) {
state . settingsOpen = false ;
changed = true ;
2026-04-01 12:31:56 -07:00
}
2026-04-01 16:08:52 -07:00
if ( state . layoutMenuOpen && ! tarotFrameLayoutPanelEl ? . contains ( target ) && ! tarotFrameLayoutToggleEl ? . contains ( target ) ) {
state . layoutMenuOpen = false ;
changed = true ;
}
2026-04-04 03:39:29 -07:00
if ( state . cardPicker . open && cardPickerEl && ! cardPickerEl . contains ( target ) ) {
closeCardPicker ( ) ;
}
2026-04-01 16:08:52 -07:00
if ( changed ) {
syncControls ( ) ;
}
2026-04-01 12:31:56 -07:00
}
function handleDocumentKeydown ( event ) {
2026-04-01 16:08:52 -07:00
if ( event . key !== "Escape" ) {
2026-04-01 12:31:56 -07:00
return ;
}
2026-04-04 03:39:29 -07:00
if ( state . gridFocusMode ) {
setGridFocusMode ( false ) ;
return ;
}
2026-04-01 16:08:52 -07:00
let changed = false ;
if ( state . settingsOpen ) {
state . settingsOpen = false ;
changed = true ;
}
if ( state . layoutMenuOpen ) {
state . layoutMenuOpen = false ;
changed = true ;
}
2026-04-04 03:39:29 -07:00
if ( state . cardPicker . open ) {
closeCardPicker ( ) ;
}
2026-04-01 16:08:52 -07:00
if ( changed ) {
syncControls ( ) ;
}
2026-04-01 12:31:56 -07:00
}
function drawRoundedRectPath ( context , x , y , width , height , radius ) {
const nextRadius = Math . max ( 0 , Math . min ( radius , width / 2 , height / 2 ) ) ;
context . beginPath ( ) ;
context . moveTo ( x + nextRadius , y ) ;
context . lineTo ( x + width - nextRadius , y ) ;
context . quadraticCurveTo ( x + width , y , x + width , y + nextRadius ) ;
context . lineTo ( x + width , y + height - nextRadius ) ;
context . quadraticCurveTo ( x + width , y + height , x + width - nextRadius , y + height ) ;
context . lineTo ( x + nextRadius , y + height ) ;
context . quadraticCurveTo ( x , y + height , x , y + height - nextRadius ) ;
context . lineTo ( x , y + nextRadius ) ;
context . quadraticCurveTo ( x , y , x + nextRadius , y ) ;
context . closePath ( ) ;
}
function fitCanvasLabelText ( context , text , maxWidth ) {
const normalized = normalizeLabelText ( text ) ;
if ( ! normalized || context . measureText ( normalized ) . width <= maxWidth ) {
return normalized ;
}
let result = normalized ;
while ( result . length > 1 && context . measureText ( ` ${ result } ... ` ) . width > maxWidth ) {
result = result . slice ( 0 , - 1 ) . trimEnd ( ) ;
}
return ` ${ result } ... ` ;
}
function wrapCanvasText ( context , text , maxWidth , maxLines = 2 ) {
const normalized = normalizeLabelText ( text ) ;
if ( ! normalized ) {
return [ ] ;
}
const words = normalized . split ( /\s+/ ) . filter ( Boolean ) ;
const lines = [ ] ;
let current = "" ;
words . forEach ( ( word ) => {
const next = current ? ` ${ current } ${ word } ` : word ;
if ( current && context . measureText ( next ) . width > maxWidth ) {
lines . push ( current ) ;
current = word ;
} else {
current = next ;
}
} ) ;
if ( current ) {
lines . push ( current ) ;
}
if ( lines . length <= maxLines ) {
return lines ;
}
const clipped = lines . slice ( 0 , Math . max ( 1 , maxLines ) ) ;
clipped [ clipped . length - 1 ] = fitCanvasLabelText ( context , clipped [ clipped . length - 1 ] , maxWidth ) ;
return clipped ;
}
function drawImageContain ( context , image , x , y , width , height ) {
if ( ! ( image instanceof HTMLImageElement ) && ! ( image instanceof ImageBitmap ) ) {
return ;
}
const sourceWidth = Number ( image . width || image . naturalWidth || 0 ) ;
const sourceHeight = Number ( image . height || image . naturalHeight || 0 ) ;
if ( ! ( sourceWidth > 0 && sourceHeight > 0 ) ) {
return ;
}
const scale = Math . min ( width / sourceWidth , height / sourceHeight ) ;
const drawWidth = sourceWidth * scale ;
const drawHeight = sourceHeight * scale ;
const drawX = x + ( ( width - drawWidth ) / 2 ) ;
const drawY = y + ( ( height - drawHeight ) / 2 ) ;
context . drawImage ( image , drawX , drawY , drawWidth , drawHeight ) ;
}
2026-04-01 19:26:38 -07:00
function drawTextFaceToCanvas ( context , x , y , width , height , faceModel ) {
2026-04-01 16:08:52 -07:00
const primaryText = normalizeLabelText ( faceModel ? . primary || "Tarot" ) ;
const secondaryText = normalizeLabelText ( faceModel ? . secondary ) ;
2026-04-01 19:26:38 -07:00
const maxWidth = width - 12 ;
2026-04-01 16:08:52 -07:00
context . save ( ) ;
const primaryFontSize = faceModel ? . className === "is-top-hebrew" && primaryText . length <= 3 ? 14 : 10 ;
const primaryFontFamily = faceModel ? . className === "is-top-hebrew"
? "'Segoe UI Symbol', 'Noto Sans Hebrew', 'Segoe UI', sans-serif"
: "'Segoe UI', sans-serif" ;
context . font = ` 700 ${ primaryFontSize } px ${ primaryFontFamily } ` ;
const primaryLines = wrapCanvasText ( context , primaryText , maxWidth , secondaryText ? 3 : 4 ) ;
const secondaryLines = secondaryText ? wrapCanvasText ( context , secondaryText , maxWidth , 3 ) : [ ] ;
const primaryLineHeight = faceModel ? . className === "is-top-hebrew" && primaryText . length <= 3 ? 14 : 11 ;
const secondaryLineHeight = 9 ;
const totalHeight = ( primaryLines . length * primaryLineHeight ) + ( secondaryLines . length ? 4 + ( secondaryLines . length * secondaryLineHeight ) : 0 ) ;
2026-04-01 19:26:38 -07:00
let currentY = y + ( ( height - totalHeight ) / 2 ) + primaryLineHeight ;
2026-04-01 16:08:52 -07:00
context . textAlign = "center" ;
context . textBaseline = "alphabetic" ;
primaryLines . forEach ( ( line ) => {
context . fillStyle = "#f8fafc" ;
context . font = ` 700 ${ primaryFontSize } px ${ primaryFontFamily } ` ;
2026-04-01 19:26:38 -07:00
context . fillText ( line , x + ( width / 2 ) , currentY , maxWidth ) ;
2026-04-01 16:08:52 -07:00
currentY += primaryLineHeight ;
} ) ;
if ( secondaryLines . length ) {
currentY += 2 ;
context . fillStyle = "rgba(248, 250, 252, 0.78)" ;
context . font = "500 7px 'Segoe UI', sans-serif" ;
secondaryLines . forEach ( ( line ) => {
2026-04-01 19:26:38 -07:00
context . fillText ( line , x + ( width / 2 ) , currentY , maxWidth ) ;
2026-04-01 16:08:52 -07:00
currentY += secondaryLineHeight ;
} ) ;
}
context . restore ( ) ;
}
2026-04-01 19:26:38 -07:00
function drawSlotToCanvas ( context , x , y , width , height , card , image ) {
2026-04-01 12:31:56 -07:00
if ( ! card ) {
context . save ( ) ;
context . setLineDash ( [ 6 , 6 ] ) ;
context . lineWidth = 1.5 ;
context . strokeStyle = "rgba(148, 163, 184, 0.42)" ;
2026-04-01 19:26:38 -07:00
drawRoundedRectPath ( context , x + 1 , y + 1 , width - 2 , height - 2 , 10 ) ;
2026-04-01 12:31:56 -07:00
context . stroke ( ) ;
context . restore ( ) ;
return ;
}
const cardX = x + EXPORT _CARD _INSET ;
const cardY = y + EXPORT _CARD _INSET ;
2026-04-01 19:26:38 -07:00
const cardWidth = width - ( EXPORT _CARD _INSET * 2 ) ;
const cardHeight = height - ( EXPORT _CARD _INSET * 2 ) ;
2026-04-01 16:08:52 -07:00
const showImage = shouldShowCardImage ( card ) ;
2026-04-01 12:31:56 -07:00
context . save ( ) ;
2026-04-01 19:26:38 -07:00
drawRoundedRectPath ( context , cardX , cardY , cardWidth , cardHeight , 0 ) ;
2026-04-01 12:31:56 -07:00
context . clip ( ) ;
2026-04-01 16:08:52 -07:00
if ( showImage && image ) {
2026-04-01 19:26:38 -07:00
drawImageContain ( context , image , cardX , cardY , cardWidth , cardHeight ) ;
2026-04-01 16:08:52 -07:00
} else if ( showImage ) {
2026-04-01 12:31:56 -07:00
context . fillStyle = EXPORT _PANEL ;
2026-04-01 19:26:38 -07:00
context . fillRect ( cardX , cardY , cardWidth , cardHeight ) ;
2026-04-01 12:31:56 -07:00
context . fillStyle = "#f8fafc" ;
context . textAlign = "center" ;
context . textBaseline = "middle" ;
context . font = "700 14px 'Segoe UI', sans-serif" ;
2026-04-01 19:26:38 -07:00
const lines = wrapCanvasText ( context , getDisplayCardName ( card ) , cardWidth - 18 , 4 ) ;
2026-04-01 12:31:56 -07:00
const lineHeight = 18 ;
2026-04-01 19:26:38 -07:00
let currentY = cardY + ( cardHeight / 2 ) - ( ( ( Math . max ( 1 , lines . length ) - 1 ) * lineHeight ) / 2 ) ;
2026-04-01 12:31:56 -07:00
lines . forEach ( ( line ) => {
2026-04-01 19:26:38 -07:00
context . fillText ( line , cardX + ( cardWidth / 2 ) , currentY , cardWidth - 18 ) ;
2026-04-01 12:31:56 -07:00
currentY += lineHeight ;
} ) ;
2026-04-01 16:08:52 -07:00
} else {
context . fillStyle = EXPORT _PANEL ;
2026-04-01 19:26:38 -07:00
context . fillRect ( cardX , cardY , cardWidth , cardHeight ) ;
drawTextFaceToCanvas ( context , cardX , cardY , cardWidth , cardHeight , buildCardTextFaceModel ( card ) ) ;
2026-04-01 12:31:56 -07:00
}
context . restore ( ) ;
2026-04-01 16:08:52 -07:00
if ( showImage && state . showInfo ) {
2026-04-01 12:31:56 -07:00
const overlayText = getCardOverlayLabel ( card ) ;
if ( overlayText ) {
const overlayHeight = 30 ;
const overlayX = cardX + 4 ;
2026-04-01 19:26:38 -07:00
const overlayY = cardY + cardHeight - overlayHeight - 4 ;
const overlayWidth = cardWidth - 8 ;
2026-04-01 12:31:56 -07:00
drawRoundedRectPath ( context , overlayX , overlayY , overlayWidth , overlayHeight , 8 ) ;
context . fillStyle = EXPORT _BADGE _BACKGROUND ;
context . fill ( ) ;
context . fillStyle = EXPORT _BADGE _TEXT ;
context . textAlign = "center" ;
context . textBaseline = "middle" ;
context . font = "700 11px 'Segoe UI', sans-serif" ;
const lines = wrapCanvasText ( context , overlayText , overlayWidth - 10 , 2 ) ;
const lineHeight = 12 ;
let currentY = overlayY + ( overlayHeight / 2 ) - ( ( ( Math . max ( 1 , lines . length ) - 1 ) * lineHeight ) / 2 ) ;
lines . forEach ( ( line ) => {
context . fillText ( line , overlayX + ( overlayWidth / 2 ) , currentY , overlayWidth - 10 ) ;
currentY += lineHeight ;
} ) ;
}
}
}
function loadCardImage ( src ) {
return new Promise ( ( resolve ) => {
const image = new Image ( ) ;
image . crossOrigin = "anonymous" ;
image . decoding = "async" ;
image . onload = ( ) => resolve ( image ) ;
image . onerror = ( ) => resolve ( null ) ;
image . src = src ;
} ) ;
}
function isExportFormatSupported ( format ) {
const exportFormat = EXPORT _FORMATS [ format ] ;
if ( ! exportFormat ) {
return false ;
}
const probeCanvas = document . createElement ( "canvas" ) ;
const dataUrl = probeCanvas . toDataURL ( exportFormat . mimeType ) ;
return dataUrl . startsWith ( ` data: ${ exportFormat . mimeType } ` ) ;
}
function canvasToBlobByFormat ( canvas , format ) {
const exportFormat = EXPORT _FORMATS [ format ] || EXPORT _FORMATS . webp ;
return new Promise ( ( resolve , reject ) => {
canvas . toBlob ( ( blob ) => {
if ( blob ) {
resolve ( blob ) ;
return ;
}
reject ( new Error ( "Canvas export failed." ) ) ;
} , exportFormat . mimeType , exportFormat . quality ) ;
} ) ;
}
async function exportImage ( format = "webp" ) {
const cards = getCards ( ) ;
const cardMap = getCardMap ( cards ) ;
const exportFormat = EXPORT _FORMATS [ format ] || EXPORT _FORMATS . webp ;
2026-04-01 19:26:38 -07:00
const contentWidth = ( MASTER _GRID _SIZE * EXPORT _SLOT _WIDTH ) + ( ( MASTER _GRID _SIZE - 1 ) * EXPORT _GRID _GAP ) ;
const contentHeight = ( MASTER _GRID _SIZE * EXPORT _SLOT _HEIGHT ) + ( ( MASTER _GRID _SIZE - 1 ) * EXPORT _GRID _GAP ) ;
const canvasWidth = contentWidth + ( EXPORT _PADDING * 2 ) ;
const canvasHeight = contentHeight + ( EXPORT _PADDING * 2 ) ;
2026-04-01 12:31:56 -07:00
const scale = Math . max ( 1.5 , Math . min ( 2 , Number ( window . devicePixelRatio ) || 1 ) ) ;
const canvas = document . createElement ( "canvas" ) ;
2026-04-01 19:26:38 -07:00
canvas . width = Math . ceil ( canvasWidth * scale ) ;
canvas . height = Math . ceil ( canvasHeight * scale ) ;
canvas . style . width = ` ${ canvasWidth } px ` ;
canvas . style . height = ` ${ canvasHeight } px ` ;
2026-04-01 12:31:56 -07:00
const context = canvas . getContext ( "2d" ) ;
if ( ! context ) {
throw new Error ( "Canvas context is unavailable." ) ;
}
context . scale ( scale , scale ) ;
context . imageSmoothingEnabled = true ;
context . imageSmoothingQuality = "high" ;
context . fillStyle = EXPORT _BACKGROUND ;
2026-04-01 19:26:38 -07:00
context . fillRect ( 0 , 0 , canvasWidth , canvasHeight ) ;
2026-04-01 12:31:56 -07:00
const imageCache = new Map ( ) ;
cards . forEach ( ( card ) => {
const src = resolveCardThumbnail ( card ) ;
if ( src && ! imageCache . has ( src ) ) {
imageCache . set ( src , loadCardImage ( src ) ) ;
}
} ) ;
const resolvedImages = new Map ( ) ;
await Promise . all ( cards . map ( async ( card ) => {
const src = resolveCardThumbnail ( card ) ;
const image = src ? await imageCache . get ( src ) : null ;
resolvedImages . set ( getCardId ( card ) , image || null ) ;
} ) ) ;
for ( let row = 1 ; row <= MASTER _GRID _SIZE ; row += 1 ) {
for ( let column = 1 ; column <= MASTER _GRID _SIZE ; column += 1 ) {
const slotId = getSlotId ( row , column ) ;
const card = getAssignedCard ( slotId , cardMap ) ;
2026-04-01 19:26:38 -07:00
const x = EXPORT _PADDING + ( ( column - 1 ) * ( EXPORT _SLOT _WIDTH + EXPORT _GRID _GAP ) ) ;
const y = EXPORT _PADDING + ( ( row - 1 ) * ( EXPORT _SLOT _HEIGHT + EXPORT _GRID _GAP ) ) ;
drawSlotToCanvas ( context , x , y , EXPORT _SLOT _WIDTH , EXPORT _SLOT _HEIGHT , card , card ? resolvedImages . get ( getCardId ( card ) ) : null ) ;
2026-04-01 12:31:56 -07:00
}
}
const blob = await canvasToBlobByFormat ( canvas , format ) ;
const blobUrl = URL . createObjectURL ( blob ) ;
const downloadLink = document . createElement ( "a" ) ;
const stamp = new Date ( ) . toISOString ( ) . slice ( 0 , 10 ) ;
downloadLink . href = blobUrl ;
downloadLink . download = ` tarot-frame-grid- ${ stamp } . ${ exportFormat . extension } ` ;
document . body . appendChild ( downloadLink ) ;
downloadLink . click ( ) ;
downloadLink . remove ( ) ;
setTimeout ( ( ) => URL . revokeObjectURL ( blobUrl ) , 1000 ) ;
}
async function exportFrame ( format = "webp" ) {
if ( state . exportInProgress ) {
return ;
}
state . exportInProgress = true ;
state . exportFormat = format ;
syncControls ( ) ;
try {
await exportImage ( format ) ;
setStatus ( ` Downloaded a ${ String ( format || "webp" ) . toUpperCase ( ) } export of the current frame grid. ` ) ;
} catch ( error ) {
window . alert ( error instanceof Error ? error . message : "Unable to export the Tarot Frame image." ) ;
} finally {
state . exportInProgress = false ;
state . exportFormat = "webp" ;
syncControls ( ) ;
}
}
function bindEvents ( ) {
const {
tarotFrameBoardEl ,
2026-04-04 03:39:29 -07:00
tarotFrameOverviewEl ,
tarotFramePanToggleEl ,
tarotFrameFocusToggleEl ,
tarotFrameFocusExitEl ,
2026-04-01 16:08:52 -07:00
tarotFrameLayoutToggleEl ,
tarotFrameLayoutPanelEl ,
2026-04-01 12:31:56 -07:00
tarotFrameSettingsToggleEl ,
tarotFrameSettingsPanelEl ,
2026-04-01 19:26:38 -07:00
tarotFrameGridZoomEl ,
2026-04-01 12:31:56 -07:00
tarotFrameShowInfoEl ,
2026-04-01 16:08:52 -07:00
tarotFrameHouseTopCardsVisibleEl ,
tarotFrameHouseTopInfoHebrewEl ,
tarotFrameHouseTopInfoPlanetEl ,
tarotFrameHouseTopInfoZodiacEl ,
tarotFrameHouseTopInfoTrumpEl ,
tarotFrameHouseTopInfoPathEl ,
tarotFrameHouseTopInfoDateEl ,
tarotFrameHouseBottomCardsVisibleEl ,
tarotFrameHouseBottomInfoZodiacEl ,
tarotFrameHouseBottomInfoDecanEl ,
tarotFrameHouseBottomInfoMonthEl ,
tarotFrameHouseBottomInfoRulerEl ,
tarotFrameHouseBottomInfoDateEl ,
2026-04-04 03:39:29 -07:00
tarotFrameClearGridEl ,
2026-04-01 12:31:56 -07:00
tarotFrameExportWebpEl
} = getElements ( ) ;
if ( tarotFrameBoardEl ) {
tarotFrameBoardEl . addEventListener ( "pointerdown" , handlePointerDown ) ;
tarotFrameBoardEl . addEventListener ( "click" , handleBoardClick ) ;
tarotFrameBoardEl . addEventListener ( "dragstart" , handleNativeDragStart ) ;
2026-04-04 03:39:29 -07:00
tarotFrameBoardEl . addEventListener ( "contextmenu" , handleBoardContextMenu ) ;
tarotFrameBoardEl . addEventListener ( "touchstart" , handleBoardTouchStart , { passive : false } ) ;
}
2026-04-04 12:15:52 -07:00
document . addEventListener ( "touchstart" , handleDocumentTouchStart , {
capture : true ,
passive : true
} ) ;
2026-04-04 03:39:29 -07:00
if ( tarotFrameOverviewEl ) {
tarotFrameOverviewEl . addEventListener ( "input" , ( event ) => {
const target = event . target ;
if ( ! ( target instanceof HTMLTextAreaElement ) || target . id !== "tarot-frame-layout-note" ) {
return ;
}
setLayoutNote ( state . currentLayoutId , target . value , { updateUi : false } ) ;
const badgeEl = tarotFrameOverviewEl . querySelector ( ".tarot-frame-notes-badge" ) ;
if ( badgeEl instanceof HTMLElement ) {
badgeEl . textContent = getLayoutNote ( ) ? "Saved" : "Optional" ;
}
const clearButton = tarotFrameOverviewEl . querySelector ( "[data-frame-note-clear='true']" ) ;
if ( clearButton instanceof HTMLButtonElement ) {
clearButton . disabled = ! getLayoutNote ( ) || Boolean ( state . exportInProgress ) ;
}
} ) ;
tarotFrameOverviewEl . addEventListener ( "click" , ( event ) => {
const target = event . target ;
if ( ! ( target instanceof Element ) ) {
return ;
}
const clearButton = target . closest ( "[data-frame-note-clear='true']" ) ;
if ( ! ( clearButton instanceof HTMLButtonElement ) ) {
return ;
}
setLayoutNote ( state . currentLayoutId , "" ) ;
} ) ;
}
if ( tarotFramePanToggleEl ) {
tarotFramePanToggleEl . addEventListener ( "click" , ( event ) => {
event . stopPropagation ( ) ;
if ( state . exportInProgress ) {
return ;
}
state . panMode = ! state . panMode ;
finishPanGesture ( ) ;
clearLongPressGesture ( ) ;
syncControls ( ) ;
updateViewportInteractionState ( ) ;
setStatus ( state . panMode
? "Pan mode enabled. Drag inside the frame grid to move around."
: "Pan mode disabled. Drag cards to rearrange the layout." ) ;
} ) ;
}
if ( tarotFrameFocusToggleEl ) {
tarotFrameFocusToggleEl . addEventListener ( "click" , ( event ) => {
event . stopPropagation ( ) ;
if ( state . exportInProgress ) {
return ;
}
setGridFocusMode ( ! state . gridFocusMode ) ;
} ) ;
}
if ( tarotFrameFocusExitEl ) {
tarotFrameFocusExitEl . addEventListener ( "click" , ( event ) => {
event . stopPropagation ( ) ;
setGridFocusMode ( false ) ;
} ) ;
2026-04-01 12:31:56 -07:00
}
2026-04-01 16:08:52 -07:00
if ( tarotFrameLayoutToggleEl ) {
tarotFrameLayoutToggleEl . addEventListener ( "click" , ( event ) => {
event . stopPropagation ( ) ;
if ( state . exportInProgress ) {
return ;
}
state . layoutMenuOpen = ! state . layoutMenuOpen ;
if ( state . layoutMenuOpen ) {
state . settingsOpen = false ;
}
syncControls ( ) ;
} ) ;
}
if ( tarotFrameLayoutPanelEl ) {
tarotFrameLayoutPanelEl . addEventListener ( "click" , ( event ) => {
event . stopPropagation ( ) ;
const target = event . target ;
2026-04-04 03:39:29 -07:00
if ( ! ( target instanceof Element ) ) {
return ;
}
const saveButton = target . closest ( "[data-layout-save-action='true']" ) ;
if ( saveButton instanceof HTMLButtonElement ) {
saveCurrentLayout ( ) ;
return ;
}
const deleteButton = target . closest ( ".tarot-frame-layout-delete-btn[data-layout-delete-id]" ) ;
if ( deleteButton instanceof HTMLButtonElement ) {
deleteSavedLayout ( deleteButton . dataset . layoutDeleteId ) ;
return ;
}
const option = target . closest ( ".tarot-frame-layout-option[data-layout-id]" ) ;
2026-04-01 16:08:52 -07:00
if ( ! ( option instanceof HTMLButtonElement ) ) {
return ;
}
2026-04-01 12:31:56 -07:00
const cards = getCards ( ) ;
if ( ! cards . length ) {
return ;
}
2026-04-01 16:08:52 -07:00
2026-04-04 03:39:29 -07:00
const selectedLayout = getLayoutDefinition ( option . dataset . layoutId ) ;
applyLayoutSelection ( option . dataset . layoutId , cards , ` ${ selectedLayout . label } layout applied to the master grid. ` ) ;
2026-04-01 16:08:52 -07:00
state . layoutMenuOpen = false ;
2026-04-01 12:31:56 -07:00
render ( ) ;
2026-04-01 16:08:52 -07:00
syncControls ( ) ;
2026-04-01 12:31:56 -07:00
} ) ;
}
if ( tarotFrameSettingsToggleEl ) {
tarotFrameSettingsToggleEl . addEventListener ( "click" , ( event ) => {
event . stopPropagation ( ) ;
if ( state . exportInProgress ) {
return ;
}
state . settingsOpen = ! state . settingsOpen ;
2026-04-01 16:08:52 -07:00
if ( state . settingsOpen ) {
state . layoutMenuOpen = false ;
}
2026-04-01 12:31:56 -07:00
syncControls ( ) ;
} ) ;
}
if ( tarotFrameSettingsPanelEl ) {
tarotFrameSettingsPanelEl . addEventListener ( "click" , ( event ) => {
event . stopPropagation ( ) ;
} ) ;
}
if ( tarotFrameShowInfoEl ) {
tarotFrameShowInfoEl . addEventListener ( "change" , ( ) => {
state . showInfo = Boolean ( tarotFrameShowInfoEl . checked ) ;
render ( ) ;
syncControls ( ) ;
} ) ;
}
2026-04-01 19:26:38 -07:00
if ( tarotFrameGridZoomEl ) {
tarotFrameGridZoomEl . addEventListener ( "change" , ( ) => {
setGridZoomStepIndex ( tarotFrameGridZoomEl . value ) ;
} ) ;
}
2026-04-01 16:08:52 -07:00
[
[ tarotFrameHouseTopCardsVisibleEl , ( checked ) => config . setHouseTopCardsVisible ? . ( checked ) ] ,
[ tarotFrameHouseTopInfoHebrewEl , ( checked ) => config . setHouseTopInfoMode ? . ( "hebrew" , checked ) ] ,
[ tarotFrameHouseTopInfoPlanetEl , ( checked ) => config . setHouseTopInfoMode ? . ( "planet" , checked ) ] ,
[ tarotFrameHouseTopInfoZodiacEl , ( checked ) => config . setHouseTopInfoMode ? . ( "zodiac" , checked ) ] ,
[ tarotFrameHouseTopInfoTrumpEl , ( checked ) => config . setHouseTopInfoMode ? . ( "trump" , checked ) ] ,
[ tarotFrameHouseTopInfoPathEl , ( checked ) => config . setHouseTopInfoMode ? . ( "path" , checked ) ] ,
[ tarotFrameHouseTopInfoDateEl , ( checked ) => config . setHouseTopInfoMode ? . ( "date" , checked ) ] ,
[ tarotFrameHouseBottomCardsVisibleEl , ( checked ) => config . setHouseBottomCardsVisible ? . ( checked ) ] ,
[ tarotFrameHouseBottomInfoZodiacEl , ( checked ) => config . setHouseBottomInfoMode ? . ( "zodiac" , checked ) ] ,
[ tarotFrameHouseBottomInfoDecanEl , ( checked ) => config . setHouseBottomInfoMode ? . ( "decan" , checked ) ] ,
[ tarotFrameHouseBottomInfoMonthEl , ( checked ) => config . setHouseBottomInfoMode ? . ( "month" , checked ) ] ,
[ tarotFrameHouseBottomInfoRulerEl , ( checked ) => config . setHouseBottomInfoMode ? . ( "ruler" , checked ) ] ,
[ tarotFrameHouseBottomInfoDateEl , ( checked ) => config . setHouseBottomInfoMode ? . ( "date" , checked ) ]
] . forEach ( ( [ element , callback ] ) => {
if ( ! element ) {
return ;
}
element . addEventListener ( "change" , ( ) => {
callback ( Boolean ( element . checked ) ) ;
render ( ) ;
syncControls ( ) ;
} ) ;
} ) ;
2026-04-01 12:31:56 -07:00
if ( tarotFrameExportWebpEl ) {
tarotFrameExportWebpEl . addEventListener ( "click" , ( ) => {
exportFrame ( "webp" ) ;
} ) ;
}
2026-04-04 03:39:29 -07:00
if ( tarotFrameClearGridEl ) {
tarotFrameClearGridEl . addEventListener ( "click" , ( ) => {
clearGrid ( ) ;
} ) ;
}
2026-04-01 12:31:56 -07:00
document . addEventListener ( "click" , handleDocumentClick ) ;
document . addEventListener ( "keydown" , handleDocumentKeydown ) ;
}
async function ensureTarotFrameSection ( referenceData , magickDataset ) {
if ( typeof config . ensureTarotSection === "function" ) {
await config . ensureTarotSection ( referenceData , magickDataset ) ;
}
2026-04-02 01:10:50 -07:00
resetFrameSectionScroll ( ) ;
2026-04-01 12:31:56 -07:00
const cards = getCards ( ) ;
if ( ! cards . length ) {
setStatus ( "Tarot cards are still loading..." ) ;
return ;
}
const signature = buildCardSignature ( cards ) ;
if ( ! state . layoutReady || state . cardSignature !== signature ) {
state . cardSignature = signature ;
2026-04-04 03:39:29 -07:00
applyLayoutSelection ( state . currentLayoutId , cards ) ;
2026-04-01 12:31:56 -07:00
} else {
setStatus ( state . statusMessage || buildReadyStatus ( cards ) ) ;
}
render ( ) ;
syncControls ( ) ;
}
function init ( nextConfig = { } ) {
config = {
... config ,
... nextConfig
} ;
if ( state . initialized ) {
return ;
}
2026-04-04 03:39:29 -07:00
loadSavedLayoutsFromStorage ( ) ;
loadLayoutNotesFromStorage ( ) ;
restoreActiveLayoutId ( ) ;
restoreCardPickerQuery ( ) ;
2026-04-01 12:31:56 -07:00
bindEvents ( ) ;
2026-04-04 03:39:29 -07:00
createCardPickerElements ( ) ;
2026-04-01 12:31:56 -07:00
syncControls ( ) ;
state . initialized = true ;
}
window . TarotFrameUi = {
... ( window . TarotFrameUi || { } ) ,
init ,
ensureTarotFrameSection ,
render ,
resetLayout ,
2026-04-01 16:08:52 -07:00
setLayoutPreset ( layoutId , options = { } ) {
const cards = getCards ( ) ;
2026-04-04 03:39:29 -07:00
state . currentLayoutId = getLayoutDefinition ( layoutId ) . id ;
2026-04-01 16:08:52 -07:00
if ( cards . length && options . reapply !== false ) {
2026-04-04 03:39:29 -07:00
applyLayoutSelection ( state . currentLayoutId , cards , options . statusMessage || ` ${ getLayoutDefinition ( layoutId ) . label } layout applied to the master grid. ` ) ;
2026-04-01 16:08:52 -07:00
render ( ) ;
}
syncControls ( ) ;
} ,
2026-04-01 12:31:56 -07:00
exportImage ,
isExportFormatSupported
} ;
} ) ( ) ;