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-06 20:22:31 -07:00
const FRAME_GRID_ZOOM_STEPS = [ 1 , 1.2 , 1.4 , 1.7 , 2 , 2.4 , 3 , 3.6 , 4.2 , 5.2 , 6.4 , 8 , 10 , 12 , 14 ];
2026-04-04 03:39:29 -07:00
const FRAME_GRID_MIN_SCALE = 0.8 ;
2026-04-06 20:22:31 -07:00
const FRAME_GRID_MAX_SCALE = 14 ;
2026-04-04 03:39:29 -07:00
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-07 03:16:37 -07:00
const FRAME_CUSTOM_CARD_PREFIX = "frame-custom-text:" ;
const FRAME_CUSTOM_CARD_ACTION_ID = "create-custom-text" ;
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 (),
2026-04-13 14:28:03 -07:00
slotFlips : new Map (),
selectedSlotIds : new Set (),
2026-04-01 12:31:56 -07:00
statusMessage : "Loading tarot cards..." ,
drag : null ,
2026-04-04 03:39:29 -07:00
panMode : false ,
panGesture : null ,
pinchGesture : null ,
longPress : null ,
2026-04-07 03:16:37 -07:00
cardInsertMenu : {
open : false ,
slotId : ""
},
2026-04-13 14:28:03 -07:00
cardActionMenu : {
open : false ,
slotId : "" ,
cardId : "" ,
ignoreClickUntil : 0 ,
clickArmed : false
},
2026-04-04 03:39:29 -07:00
cardPicker : {
open : false ,
slotId : "" ,
2026-04-07 03:16:37 -07:00
query : "" ,
mode : "browse" ,
editingCardId : "" ,
editorImageData : ""
2026-04-04 03:39:29 -07:00
},
2026-04-01 12:31:56 -07:00
suppressClick : false ,
showInfo : true ,
2026-04-06 20:22:31 -07:00
layoutGuideVisible : false ,
2026-04-01 12:31:56 -07:00
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 : [],
2026-04-07 03:16:37 -07:00
customCards : new Map (),
2026-04-04 03:39:29 -07:00
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-07 03:16:37 -07:00
let cardInsertMenuEl = null ;
2026-04-13 14:28:03 -07:00
let cardActionMenuEl = null ;
let cardActionMenuSelectEl = null ;
let cardActionMenuFlipEl = null ;
let cardActionMenuOpenEl = null ;
let cardActionMenuRemoveEl = null ;
2026-04-04 03:39:29 -07:00
let cardPickerEl = null ;
let cardPickerTitleEl = null ;
let cardPickerSearchEl = null ;
let cardPickerSectionsEl = null ;
2026-04-07 03:16:37 -07:00
let cardPickerSearchWrapEl = null ;
let cardPickerEditorHeadingEl = null ;
let cardPickerEditorTitleEl = null ;
let cardPickerEditorSubtitleEl = null ;
let cardPickerEditorColorEl = null ;
let cardPickerEditorImageEl = null ;
let cardPickerEditorImageUploadEl = null ;
let cardPickerEditorImageClearEl = null ;
let cardPickerEditorPreviewEl = null ;
let cardPickerEditorSaveEl = null ;
let cardPickerEditorRemoveEl = null ;
let cardCustomEditorEl = null ;
let cardCustomEditorTitleEl = null ;
let dragTrashEl = null ;
2026-04-04 03:39:29 -07:00
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" ),
2026-04-13 14:28:03 -07:00
tarotFrameSelectionChipEl : document . getElementById ( "tarot-frame-selection-chip" ),
2026-04-04 03:39:29 -07:00
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" )
};
}
function normalizeLabelText ( value ) {
return String ( value || "" ). replace ( /\s+/g , " " ). trim ();
}
2026-04-13 14:28:03 -07:00
function syncSelectionChip () {
const { tarotFrameSelectionChipEl } = getElements ();
if ( ! ( tarotFrameSelectionChipEl instanceof HTMLButtonElement )) {
return ;
}
const selectedCount = getSelectedSlotIds (). length ;
tarotFrameSelectionChipEl . hidden = ! ( selectedCount > 0 );
tarotFrameSelectionChipEl . disabled = ! ( selectedCount > 0 ) || Boolean ( state . exportInProgress );
tarotFrameSelectionChipEl . classList . toggle ( "is-active" , selectedCount > 0 );
tarotFrameSelectionChipEl . textContent = selectedCount === 1
? "1 Selected · Clear"
: ` ${ selectedCount } Selected · Clear` ;
tarotFrameSelectionChipEl . setAttribute (
"aria-label" ,
selectedCount > 0
? `Clear selection of ${ selectedCount } card ${ selectedCount === 1 ? "" : "s" } `
: "No cards selected"
);
tarotFrameSelectionChipEl . title = selectedCount > 0
? "Clear the current multi-card selection"
: "" ;
}
2026-04-01 12:31:56 -07:00
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 ) {
2026-04-01 19:26:38 -07:00
return ` ${ Array . isArray ( cards ) ? cards . length : 0 } cards ready. Drag cards freely and use Settings to change the grid zoom for any layout.` ;
}
2026-04-04 03:39:29 -07:00
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 ;
}
2026-04-01 19:26:38 -07:00
function getGridZoomScale () {
2026-04-04 03:39:29 -07:00
return clampFrameGridZoomScale ( state . gridZoomScale );
2026-04-01 19:26:38 -07:00
}
function buildPanelCountText ( cards = getCards ()) {
return ` ${ cards . length } cards / ${ MASTER_GRID_SIZE * MASTER_GRID_SIZE } cells · Zoom ${ Math . round ( getGridZoomScale () * 100 ) } %` ;
2026-04-01 12:31:56 -07:00
}
function normalizeKey ( value ) {
return String ( value || "" ). trim (). toLowerCase ();
}
2026-04-04 03:39:29 -07:00
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 ;
}
2026-04-13 14:28:03 -07:00
function parseSlotId ( value ) {
const match = String ( value || "" ). trim (). match ( /^(\d+):(\d+)$/ );
if ( ! match ) {
return null ;
}
const row = Number ( match [ 1 ]);
const column = Number ( match [ 2 ]);
if ( ! Number . isInteger ( row ) || ! Number . isInteger ( column )) {
return null ;
}
return { row , column };
}
function compareSlotIds ( left , right ) {
const leftSlot = parseSlotId ( left );
const rightSlot = parseSlotId ( right );
if ( ! leftSlot || ! rightSlot ) {
return String ( left || "" ). localeCompare ( String ( right || "" ));
}
return ( leftSlot . row - rightSlot . row ) || ( leftSlot . column - rightSlot . column );
}
2026-04-04 03:39:29 -07:00
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 ()) {
2026-04-07 03:16:37 -07:00
const validCardIds = new Set ( Array . from ( getCardMap ( cards ). keys ()));
2026-04-04 03:39:29 -07:00
return [... state . slotAssignments . entries ()]
. map (([ slotId , cardId ]) => ({
slotId : String ( slotId || "" ). trim (),
2026-04-13 14:28:03 -07:00
cardId : String ( cardId || "" ). trim (),
isFlipped : Boolean ( state . slotFlips . get ( String ( slotId || "" ). trim ()))
2026-04-04 03:39:29 -07:00
}))
. 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 ;
});
}
2026-04-07 03:16:37 -07:00
function captureCustomCardsSnapshot () {
const assignedCustomIds = new Set (
Array . from ( state . slotAssignments . values ())
. map (( cardId ) => String ( cardId || "" ). trim ())
. filter (( cardId ) => isCustomFrameCardId ( cardId ))
);
return Array . from ( state . customCards . values ())
. filter (( card ) => assignedCustomIds . has ( getCardId ( card )))
. map (( card ) => ({
id : getCardId ( card ),
customText : normalizeCustomCardText ( card ? . customText ),
backgroundColor : normalizeCustomCardBackgroundColor ( card ? . backgroundColor ),
backgroundImage : normalizeCustomCardBackgroundImage ( card ? . backgroundImage )
}))
. filter (( card ) => card . id && ( card . customText || card . backgroundColor || card . backgroundImage ))
. sort (( left , right ) => left . id . localeCompare ( right . id ));
}
2026-04-04 03:39:29 -07:00
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 (),
2026-04-13 14:28:03 -07:00
cardId : String ( entry ? . cardId || "" ). trim (),
isFlipped : Boolean ( entry ? . isFlipped )
2026-04-04 03:39:29 -07:00
}))
. filter (( entry ) => isValidSlotId ( entry . slotId ) && entry . cardId )
: [];
2026-04-07 03:16:37 -07:00
const customCards = Array . isArray ( rawLayout ? . customCards )
? rawLayout . customCards
. map (( entry ) => normalizeSavedCustomCardRecord ( entry ))
. filter ( Boolean )
: [];
2026-04-04 03:39:29 -07:00
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 ,
2026-04-07 03:16:37 -07:00
customCards ,
2026-04-04 03:39:29 -07:00
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 ,
2026-04-07 03:16:37 -07:00
customCards : Array . isArray ( layout . customCards ) ? layout . customCards : [],
2026-04-04 03:39:29 -07:00
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" ;
}
}
2026-04-06 20:22:31 -07:00
function applyLayoutGuideUi () {
const { tarotFrameOverviewEl } = getElements ();
if ( ! ( tarotFrameOverviewEl instanceof HTMLElement )) {
return ;
}
tarotFrameOverviewEl . hidden = ! state . layoutGuideVisible ;
}
2026-04-04 03:39:29 -07:00
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" ;
2026-04-07 03:16:37 -07:00
cardPickerTitleEl . textContent = "Browse Stock Cards" ;
2026-04-04 03:39:29 -07:00
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 );
2026-04-07 03:16:37 -07:00
cardPickerSearchWrapEl = document . createElement ( "label" );
cardPickerSearchWrapEl . className = "tarot-frame-card-picker-search" ;
2026-04-04 03:39:29 -07:00
const searchLabelEl = document . createElement ( "span" );
2026-04-07 03:16:37 -07:00
searchLabelEl . textContent = "Search Cards, Associations, or Labels" ;
2026-04-04 03:39:29 -07:00
cardPickerSearchEl = document . createElement ( "input" );
cardPickerSearchEl . type = "search" ;
2026-04-07 03:16:37 -07:00
cardPickerSearchEl . placeholder = "Find by card, planet, sign, decan, label..." ;
2026-04-04 03:39:29 -07:00
cardPickerSearchEl . autocomplete = "off" ;
cardPickerSearchEl . spellcheck = false ;
cardPickerSearchEl . addEventListener ( "input" , () => {
state . cardPicker . query = String ( cardPickerSearchEl . value || "" );
persistCardPickerQuery ();
renderCardPickerSections ();
});
2026-04-07 03:16:37 -07:00
cardPickerSearchWrapEl . append ( searchLabelEl , cardPickerSearchEl );
2026-04-04 03:39:29 -07:00
cardPickerSectionsEl = document . createElement ( "div" );
cardPickerSectionsEl . className = "tarot-frame-card-picker-sections" ;
2026-04-07 03:16:37 -07:00
cardPickerEl . append ( headEl , cardPickerSearchWrapEl , cardPickerSectionsEl );
2026-04-04 03:39:29 -07:00
cardPickerEl . addEventListener ( "click" , ( event ) => {
event . stopPropagation ();
const target = event . target ;
if ( ! ( target instanceof Element )) {
return ;
}
2026-04-07 03:16:37 -07:00
const cardOption = target . closest ( ".tarot-frame-card-picker-option[data-card-id]" );
if ( ! ( cardOption instanceof HTMLButtonElement )) {
2026-04-04 03:39:29 -07:00
return ;
}
2026-04-07 03:16:37 -07:00
placeCardInSlot ( state . cardPicker . slotId , cardOption . dataset . cardId );
2026-04-04 03:39:29 -07:00
closeCardPicker ();
});
document . body . appendChild ( cardPickerEl );
}
2026-04-07 03:16:37 -07:00
function createCardInsertMenuElements () {
if ( cardInsertMenuEl ) {
return ;
}
cardInsertMenuEl = document . createElement ( "div" );
cardInsertMenuEl . className = "tarot-frame-card-insert-menu" ;
cardInsertMenuEl . hidden = true ;
const libraryButtonEl = document . createElement ( "button" );
libraryButtonEl . type = "button" ;
libraryButtonEl . className = "tarot-frame-card-insert-menu-item" ;
libraryButtonEl . dataset . cardInsertAction = "library" ;
libraryButtonEl . textContent = "Card > Library" ;
const customButtonEl = document . createElement ( "button" );
customButtonEl . type = "button" ;
customButtonEl . className = "tarot-frame-card-insert-menu-item" ;
customButtonEl . dataset . cardInsertAction = "custom" ;
customButtonEl . textContent = "Card > Custom" ;
cardInsertMenuEl . append ( libraryButtonEl , customButtonEl );
cardInsertMenuEl . addEventListener ( "click" , ( event ) => {
event . stopPropagation ();
const target = event . target ;
if ( ! ( target instanceof Element )) {
return ;
}
const actionButton = target . closest ( "[data-card-insert-action]" );
if ( ! ( actionButton instanceof HTMLButtonElement )) {
return ;
}
const slotId = String ( state . cardInsertMenu . slotId || "" ). trim ();
const anchor = getCardPickerAnchor ( slotId );
closeCardInsertMenu ();
if ( actionButton . dataset . cardInsertAction === "custom" ) {
openCustomCardEditor ( slotId , "" , { anchorX : anchor . x , anchorY : anchor . y , reposition : true });
return ;
}
openCardPicker ( slotId , anchor . x , anchor . y );
});
document . body . appendChild ( cardInsertMenuEl );
}
function createCustomCardEditorElements () {
if ( cardCustomEditorEl ) {
return ;
}
cardCustomEditorEl = document . createElement ( "div" );
cardCustomEditorEl . className = "tarot-frame-card-custom-editor" ;
cardCustomEditorEl . hidden = true ;
const headEl = document . createElement ( "div" );
headEl . className = "tarot-frame-card-picker-head" ;
cardCustomEditorTitleEl = document . createElement ( "div" );
cardCustomEditorTitleEl . className = "tarot-frame-card-picker-title" ;
cardCustomEditorTitleEl . textContent = "Create Custom Card" ;
const closeButtonEl = document . createElement ( "button" );
closeButtonEl . type = "button" ;
closeButtonEl . className = "tarot-frame-card-picker-close" ;
closeButtonEl . textContent = "Close" ;
closeButtonEl . addEventListener ( "click" , () => {
const slotId = String ( state . cardPicker . slotId || "" ). trim ();
const editingCardId = String ( state . cardPicker . editingCardId || "" ). trim ();
closeCustomCardEditor ();
if ( ! editingCardId && slotId ) {
const anchor = getCardPickerAnchor ( slotId );
openCardInsertMenu ( slotId , anchor . x , anchor . y );
}
});
headEl . append ( cardCustomEditorTitleEl , closeButtonEl );
const editorEl = document . createElement ( "div" );
editorEl . className = "tarot-frame-card-picker-editor" ;
cardPickerEditorHeadingEl = document . createElement ( "div" );
cardPickerEditorHeadingEl . className = "tarot-frame-card-picker-editor-heading" ;
const editorHeadingTitleEl = document . createElement ( "strong" );
editorHeadingTitleEl . textContent = "Custom Card" ;
const editorHeadingCopyEl = document . createElement ( "span" );
editorHeadingCopyEl . textContent = "Leave text blank if you want a color or image-only card." ;
cardPickerEditorHeadingEl . append ( editorHeadingTitleEl , editorHeadingCopyEl );
const titleFieldEl = document . createElement ( "label" );
titleFieldEl . className = "tarot-frame-card-picker-editor-field" ;
const titleLabelEl = document . createElement ( "span" );
titleLabelEl . textContent = "Title" ;
cardPickerEditorTitleEl = document . createElement ( "input" );
cardPickerEditorTitleEl . type = "text" ;
cardPickerEditorTitleEl . maxLength = 120 ;
cardPickerEditorTitleEl . placeholder = "Optional title" ;
titleFieldEl . append ( titleLabelEl , cardPickerEditorTitleEl );
const subtitleFieldEl = document . createElement ( "label" );
subtitleFieldEl . className = "tarot-frame-card-picker-editor-field" ;
const subtitleLabelEl = document . createElement ( "span" );
subtitleLabelEl . textContent = "Subtitle / Notes" ;
cardPickerEditorSubtitleEl = document . createElement ( "textarea" );
cardPickerEditorSubtitleEl . rows = 3 ;
cardPickerEditorSubtitleEl . maxLength = 240 ;
cardPickerEditorSubtitleEl . placeholder = "Optional secondary text" ;
subtitleFieldEl . append ( subtitleLabelEl , cardPickerEditorSubtitleEl );
const visualGridEl = document . createElement ( "div" );
visualGridEl . className = "tarot-frame-card-picker-editor-grid" ;
const colorFieldEl = document . createElement ( "label" );
colorFieldEl . className = "tarot-frame-card-picker-editor-field" ;
const colorLabelEl = document . createElement ( "span" );
colorLabelEl . textContent = "Background Color" ;
cardPickerEditorColorEl = document . createElement ( "input" );
cardPickerEditorColorEl . type = "color" ;
cardPickerEditorColorEl . value = "#164e63" ;
colorFieldEl . append ( colorLabelEl , cardPickerEditorColorEl );
const imageFieldEl = document . createElement ( "label" );
imageFieldEl . className = "tarot-frame-card-picker-editor-field" ;
const imageLabelEl = document . createElement ( "span" );
imageLabelEl . textContent = "Background Image" ;
cardPickerEditorImageUploadEl = document . createElement ( "input" );
cardPickerEditorImageUploadEl . type = "file" ;
cardPickerEditorImageUploadEl . accept = "image/*" ;
cardPickerEditorImageEl = document . createElement ( "span" );
cardPickerEditorImageEl . className = "tarot-frame-card-picker-editor-image-meta" ;
cardPickerEditorImageEl . textContent = "No image selected." ;
imageFieldEl . append ( imageLabelEl , cardPickerEditorImageUploadEl , cardPickerEditorImageEl );
visualGridEl . append ( colorFieldEl , imageFieldEl );
const imageActionsEl = document . createElement ( "div" );
imageActionsEl . className = "tarot-frame-card-picker-editor-inline-actions" ;
cardPickerEditorImageClearEl = document . createElement ( "button" );
cardPickerEditorImageClearEl . type = "button" ;
cardPickerEditorImageClearEl . className = "tarot-frame-card-picker-editor-clear" ;
cardPickerEditorImageClearEl . textContent = "Remove Image" ;
imageActionsEl . appendChild ( cardPickerEditorImageClearEl );
const previewWrapEl = document . createElement ( "div" );
previewWrapEl . className = "tarot-frame-card-picker-editor-preview-wrap" ;
const previewLabelEl = document . createElement ( "span" );
previewLabelEl . className = "tarot-frame-card-picker-editor-preview-label" ;
previewLabelEl . textContent = "Preview" ;
cardPickerEditorPreviewEl = document . createElement ( "div" );
cardPickerEditorPreviewEl . className = "tarot-frame-card-picker-editor-preview" ;
previewWrapEl . append ( previewLabelEl , cardPickerEditorPreviewEl );
const editorActionsEl = document . createElement ( "div" );
editorActionsEl . className = "tarot-frame-card-picker-editor-actions" ;
const editorCancelEl = document . createElement ( "button" );
editorCancelEl . type = "button" ;
editorCancelEl . className = "tarot-frame-card-picker-editor-cancel" ;
editorCancelEl . dataset . cardPickerEditorCancel = "true" ;
editorCancelEl . textContent = "Back" ;
cardPickerEditorSaveEl = document . createElement ( "button" );
cardPickerEditorSaveEl . type = "button" ;
cardPickerEditorSaveEl . className = "tarot-frame-card-picker-editor-save" ;
cardPickerEditorSaveEl . dataset . cardPickerEditorSave = "true" ;
cardPickerEditorSaveEl . textContent = "Save Custom Card" ;
cardPickerEditorRemoveEl = document . createElement ( "button" );
cardPickerEditorRemoveEl . type = "button" ;
cardPickerEditorRemoveEl . className = "tarot-frame-card-picker-editor-remove" ;
cardPickerEditorRemoveEl . dataset . cardPickerEditorRemove = "true" ;
cardPickerEditorRemoveEl . textContent = "Delete Card" ;
cardPickerEditorRemoveEl . hidden = true ;
editorActionsEl . append ( editorCancelEl , cardPickerEditorSaveEl , cardPickerEditorRemoveEl );
[ cardPickerEditorTitleEl , cardPickerEditorSubtitleEl ]. forEach (( field ) => {
field . addEventListener ( "input" , () => {
updateCustomCardEditorPreview ();
});
});
cardPickerEditorColorEl . addEventListener ( "input" , () => {
cardPickerEditorColorEl . dataset . explicit = "true" ;
updateCustomCardEditorPreview ();
});
cardPickerEditorImageUploadEl . addEventListener ( "change" , () => {
const file = cardPickerEditorImageUploadEl . files ? .[ 0 ] || null ;
if ( ! file ) {
return ;
}
if ( Number ( file . size || 0 ) > 1_500_000 ) {
window . alert ( "Please choose an image under about 1.5 MB so it can be stored with the layout." );
cardPickerEditorImageUploadEl . value = "" ;
return ;
}
const reader = new FileReader ();
reader . onload = () => {
state . cardPicker . editorImageData = normalizeCustomCardBackgroundImage ( reader . result );
cardPickerEditorImageUploadEl . value = "" ;
updateCustomCardEditorPreview ();
};
reader . onerror = () => {
window . alert ( "Unable to read that image file." );
cardPickerEditorImageUploadEl . value = "" ;
};
reader . readAsDataURL ( file );
});
cardPickerEditorImageClearEl . addEventListener ( "click" , () => {
state . cardPicker . editorImageData = "" ;
if ( cardPickerEditorImageUploadEl ) {
cardPickerEditorImageUploadEl . value = "" ;
}
updateCustomCardEditorPreview ();
});
editorEl . append (
cardPickerEditorHeadingEl ,
titleFieldEl ,
subtitleFieldEl ,
visualGridEl ,
imageActionsEl ,
previewWrapEl ,
editorActionsEl
);
cardCustomEditorEl . append ( headEl , editorEl );
cardCustomEditorEl . addEventListener ( "click" , ( event ) => {
event . stopPropagation ();
const target = event . target ;
if ( ! ( target instanceof Element )) {
return ;
}
const editorCancel = target . closest ( "[data-card-picker-editor-cancel='true']" );
if ( editorCancel instanceof HTMLButtonElement ) {
const slotId = String ( state . cardPicker . slotId || "" ). trim ();
const editingCardId = String ( state . cardPicker . editingCardId || "" ). trim ();
closeCustomCardEditor ();
if ( ! editingCardId && slotId ) {
const anchor = getCardPickerAnchor ( slotId );
openCardInsertMenu ( slotId , anchor . x , anchor . y );
}
return ;
}
const editorSave = target . closest ( "[data-card-picker-editor-save='true']" );
if ( editorSave instanceof HTMLButtonElement ) {
const didSave = submitCustomCardEditor ();
if ( didSave ) {
closeCustomCardEditor ();
}
return ;
}
const editorRemove = target . closest ( "[data-card-picker-editor-remove='true']" );
if ( editorRemove instanceof HTMLButtonElement ) {
const didRemove = removeEditedCustomCard ();
if ( didRemove ) {
closeCustomCardEditor ();
}
}
});
document . body . appendChild ( cardCustomEditorEl );
}
2026-04-04 03:39:29 -07:00
function closeCardPicker () {
state . cardPicker . open = false ;
state . cardPicker . slotId = "" ;
2026-04-07 03:16:37 -07:00
state . cardPicker . mode = "browse" ;
state . cardPicker . editingCardId = "" ;
state . cardPicker . editorImageData = "" ;
2026-04-04 03:39:29 -07:00
if ( cardPickerSearchEl ) {
cardPickerSearchEl . value = state . cardPicker . query ;
}
if ( cardPickerEl ) {
cardPickerEl . hidden = true ;
}
2026-04-07 03:16:37 -07:00
if ( cardPickerEditorImageUploadEl ) {
cardPickerEditorImageUploadEl . value = "" ;
}
}
function positionCardInsertMenu ( anchorX , anchorY ) {
if ( ! ( cardInsertMenuEl instanceof HTMLElement )) {
return ;
}
cardInsertMenuEl . hidden = false ;
cardInsertMenuEl . style . visibility = "hidden" ;
requestAnimationFrame (() => {
if ( ! ( cardInsertMenuEl instanceof HTMLElement )) {
return ;
}
const panelWidth = cardInsertMenuEl . offsetWidth || 220 ;
const panelHeight = cardInsertMenuEl . offsetHeight || 120 ;
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 );
}
cardInsertMenuEl . style . left = ` ${ left } px` ;
cardInsertMenuEl . style . top = ` ${ top } px` ;
cardInsertMenuEl . style . visibility = "visible" ;
});
}
function closeCardInsertMenu () {
state . cardInsertMenu . open = false ;
state . cardInsertMenu . slotId = "" ;
if ( cardInsertMenuEl ) {
cardInsertMenuEl . hidden = true ;
}
}
2026-04-13 14:28:03 -07:00
function createCardActionMenuElements () {
if ( cardActionMenuEl ) {
return ;
}
cardActionMenuEl = document . createElement ( "div" );
cardActionMenuEl . className = "tarot-frame-card-action-menu" ;
cardActionMenuEl . hidden = true ;
cardActionMenuSelectEl = document . createElement ( "button" );
cardActionMenuSelectEl . type = "button" ;
cardActionMenuSelectEl . className = "tarot-frame-card-action-menu-item" ;
cardActionMenuSelectEl . dataset . cardAction = "select" ;
cardActionMenuSelectEl . textContent = "Select Multiple" ;
cardActionMenuFlipEl = document . createElement ( "button" );
cardActionMenuFlipEl . type = "button" ;
cardActionMenuFlipEl . className = "tarot-frame-card-action-menu-item" ;
cardActionMenuFlipEl . dataset . cardAction = "flip" ;
cardActionMenuFlipEl . textContent = "Flip Card" ;
cardActionMenuOpenEl = document . createElement ( "button" );
cardActionMenuOpenEl . type = "button" ;
cardActionMenuOpenEl . className = "tarot-frame-card-action-menu-item" ;
cardActionMenuOpenEl . dataset . cardAction = "open" ;
cardActionMenuOpenEl . textContent = "Open Card" ;
cardActionMenuRemoveEl = document . createElement ( "button" );
cardActionMenuRemoveEl . type = "button" ;
cardActionMenuRemoveEl . className = "tarot-frame-card-action-menu-item" ;
cardActionMenuRemoveEl . dataset . cardAction = "remove" ;
cardActionMenuRemoveEl . textContent = "Remove Card" ;
cardActionMenuEl . append ( cardActionMenuSelectEl , cardActionMenuFlipEl , cardActionMenuOpenEl , cardActionMenuRemoveEl );
cardActionMenuEl . addEventListener ( "pointerdown" , () => {
state . cardActionMenu . clickArmed = true ;
});
cardActionMenuEl . addEventListener ( "click" , ( event ) => {
event . stopPropagation ();
if ( ! state . cardActionMenu . clickArmed ) {
event . preventDefault ();
return ;
}
state . cardActionMenu . clickArmed = false ;
const target = event . target ;
if ( ! ( target instanceof Element )) {
return ;
}
const actionButton = target . closest ( "[data-card-action]" );
if ( ! ( actionButton instanceof HTMLButtonElement )) {
return ;
}
const slotId = String ( state . cardActionMenu . slotId || "" ). trim ();
const cardId = String ( state . cardActionMenu . cardId || "" ). trim ();
const action = String ( actionButton . dataset . cardAction || "" ). trim ();
closeCardActionMenu ();
if ( action === "select" ) {
const selectionUpdate = setSelectedSlotIds (
isSlotSelected ( slotId )
? getSelectedSlotIds (). filter (( selectedSlotId ) => selectedSlotId !== slotId )
: [... getSelectedSlotIds (), slotId ]
);
if ( selectionUpdate . changed ) {
render ({ preserveViewport : true });
}
setStatus ( buildSelectionStatusMessage ( selectionUpdate . slotIds . length ));
return ;
}
if ( action === "flip" ) {
if ( toggleCardFlip ( slotId )) {
render ({ preserveViewport : true });
}
return ;
}
if ( action === "remove" ) {
const removedCard = removeCardFromSlot ( slotId );
if ( removedCard ) {
render ({ preserveViewport : true });
setStatus ( ` ${ getDisplayCardName ( removedCard ) } removed from ${ describeSlot ( slotId ) } .` );
}
return ;
}
openCardLightbox ( cardId , slotId );
});
document . body . appendChild ( cardActionMenuEl );
}
function positionCardActionMenu ( anchorX , anchorY ) {
if ( ! ( cardActionMenuEl instanceof HTMLElement )) {
return ;
}
cardActionMenuEl . hidden = false ;
cardActionMenuEl . style . visibility = "hidden" ;
requestAnimationFrame (() => {
if ( ! ( cardActionMenuEl instanceof HTMLElement )) {
return ;
}
const panelWidth = cardActionMenuEl . offsetWidth || 220 ;
const panelHeight = cardActionMenuEl . offsetHeight || 120 ;
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 );
}
cardActionMenuEl . style . left = ` ${ left } px` ;
cardActionMenuEl . style . top = ` ${ top } px` ;
cardActionMenuEl . style . visibility = "visible" ;
});
}
function closeCardActionMenu () {
state . cardActionMenu . open = false ;
state . cardActionMenu . slotId = "" ;
state . cardActionMenu . cardId = "" ;
state . cardActionMenu . ignoreClickUntil = 0 ;
state . cardActionMenu . clickArmed = false ;
if ( cardActionMenuEl ) {
cardActionMenuEl . hidden = true ;
}
}
function syncCardActionMenu ( slotId , cardId ) {
const targetSlotId = String ( slotId || state . cardActionMenu . slotId || "" ). trim ();
const targetCardId = String ( cardId || state . cardActionMenu . cardId || "" ). trim ();
const card = getCardMap ( getCards ()). get ( targetCardId ) || null ;
const flipped = isSlotFlipped ( targetSlotId );
if ( cardActionMenuSelectEl ) {
cardActionMenuSelectEl . textContent = isSlotSelected ( targetSlotId )
? "Remove From Selection"
: ( getSelectedSlotIds (). length ? "Add To Selection" : "Select Multiple" );
}
if ( cardActionMenuFlipEl ) {
cardActionMenuFlipEl . textContent = flipped ? "Flip Upright" : "Flip Upside Down" ;
}
if ( cardActionMenuOpenEl ) {
cardActionMenuOpenEl . textContent = isCustomFrameCard ( card ) ? "Edit Custom Card" : "Open Card" ;
}
if ( cardActionMenuRemoveEl ) {
cardActionMenuRemoveEl . textContent = "Remove Card" ;
}
}
function openCardActionMenu ( slotId , cardId , anchorX , anchorY , options = {}) {
const targetSlotId = String ( slotId || "" ). trim ();
const targetCardId = String ( cardId || "" ). trim ();
if ( ! isValidSlotId ( targetSlotId ) || ! targetCardId ) {
return ;
}
const sameMenuAlreadyOpen = Boolean (
state . cardActionMenu . open
&& state . cardActionMenu . slotId === targetSlotId
&& state . cardActionMenu . cardId === targetCardId
);
const now = Number ( window . performance . now ()) || 0 ;
if ( sameMenuAlreadyOpen && now < Number ( state . cardActionMenu . ignoreClickUntil || 0 )) {
return ;
}
createCardActionMenuElements ();
closeCardInsertMenu ();
closeCardPicker ();
state . cardActionMenu . open = true ;
state . cardActionMenu . slotId = targetSlotId ;
state . cardActionMenu . cardId = targetCardId ;
state . cardActionMenu . ignoreClickUntil = options . fromTouchLongPress
? now + 500
: ( sameMenuAlreadyOpen ? Number ( state . cardActionMenu . ignoreClickUntil || 0 ) : 0 );
state . cardActionMenu . clickArmed = ! options . fromTouchLongPress ;
syncCardActionMenu ( targetSlotId , targetCardId );
positionCardActionMenu (
options . fromTouchLongPress ? anchorX + 72 : anchorX ,
options . fromTouchLongPress ? anchorY + 72 : anchorY
);
if ( options . focusFirstButton === true ) {
requestAnimationFrame (() => {
cardActionMenuFlipEl ? . focus ? .({ preventScroll : true });
});
}
}
2026-04-07 03:16:37 -07:00
function positionCustomCardEditor ( anchorX , anchorY ) {
if ( ! ( cardCustomEditorEl instanceof HTMLElement )) {
return ;
}
cardCustomEditorEl . hidden = false ;
cardCustomEditorEl . style . visibility = "hidden" ;
requestAnimationFrame (() => {
if ( ! ( cardCustomEditorEl instanceof HTMLElement )) {
return ;
}
const panelWidth = cardCustomEditorEl . offsetWidth || 360 ;
const panelHeight = cardCustomEditorEl . offsetHeight || 520 ;
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 ));
}
cardCustomEditorEl . style . left = ` ${ left } px` ;
cardCustomEditorEl . style . top = ` ${ top } px` ;
cardCustomEditorEl . style . visibility = "visible" ;
});
}
function closeCustomCardEditor () {
state . cardPicker . editingCardId = "" ;
state . cardPicker . editorImageData = "" ;
if ( cardCustomEditorEl ) {
cardCustomEditorEl . hidden = true ;
}
if ( cardPickerEditorImageUploadEl ) {
cardPickerEditorImageUploadEl . value = "" ;
}
}
function openCardInsertMenu ( slotId , anchorX , anchorY ) {
const targetSlotId = String ( slotId || "" ). trim ();
if ( ! isValidSlotId ( targetSlotId )) {
return ;
}
createCardInsertMenuElements ();
closeCardPicker ();
closeCustomCardEditor ();
state . cardInsertMenu . open = true ;
state . cardInsertMenu . slotId = targetSlotId ;
positionCardInsertMenu ( anchorX , anchorY );
requestAnimationFrame (() => {
cardInsertMenuEl ? . querySelector ( "[data-card-insert-action='library']" ) ? . focus ? .({ preventScroll : true });
});
2026-04-04 03:39:29 -07:00
}
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 ( " " );
}
2026-04-07 03:16:37 -07:00
function matchesCardPickerTerms ( queryTerms , haystack ) {
if ( ! queryTerms . length ) {
return true ;
}
return queryTerms . every (( term ) => haystack . includes ( term ));
}
function getCardPickerAnchor ( slotId ) {
const slotButton = getSlotElement ( slotId ) ? . querySelector ( ".tarot-frame-card" ) || null ;
if ( slotButton instanceof HTMLElement ) {
const rect = slotButton . getBoundingClientRect ();
return {
x : rect . left + ( rect . width / 2 ),
y : rect . top + ( rect . height / 2 )
};
}
return {
x : Math . round ( window . innerWidth / 2 ),
y : Math . round ( window . innerHeight / 2 )
};
}
function syncCardPickerTitle () {
if ( ! ( cardPickerTitleEl instanceof HTMLElement )) {
return ;
}
const slotText = describeSlot ( state . cardPicker . slotId );
cardPickerTitleEl . textContent = `Browse Stock Cards for ${ slotText } ` ;
}
function repositionCardPickerForCurrentSlot () {
if ( ! ( cardPickerEl instanceof HTMLElement ) || cardPickerEl . hidden || ! state . cardPicker . open ) {
return ;
}
const anchor = getCardPickerAnchor ( state . cardPicker . slotId );
positionCardPicker ( anchor . x , anchor . y );
}
function setCardPickerMode ( mode = "browse" ) {
state . cardPicker . mode = "browse" ;
syncCardPickerTitle ();
if ( cardPickerEl ) {
cardPickerEl . classList . toggle ( "is-browse-mode" , state . cardPicker . mode === "browse" );
}
if ( cardPickerSearchWrapEl ) {
cardPickerSearchWrapEl . hidden = false ;
}
if ( cardPickerSectionsEl ) {
cardPickerSectionsEl . hidden = false ;
}
requestAnimationFrame (() => {
repositionCardPickerForCurrentSlot ();
});
if ( state . cardPicker . mode === "browse" ) {
if ( cardPickerSearchEl ) {
cardPickerSearchEl . value = state . cardPicker . query ;
}
renderCardPickerSections ();
requestAnimationFrame (() => {
cardPickerSearchEl ? . focus ({ preventScroll : true });
});
return ;
}
}
function populateCustomCardEditor ( card = null ) {
const parsedText = parseCustomCardText ( card ? . customText );
if ( cardPickerEditorHeadingEl ) {
const titleEl = cardPickerEditorHeadingEl . querySelector ( "strong" );
if ( titleEl ) {
titleEl . textContent = card ? "Edit Custom Card" : "Create Custom Card" ;
}
}
if ( cardPickerEditorTitleEl ) {
cardPickerEditorTitleEl . value = parsedText . primary || "" ;
}
if ( cardPickerEditorSubtitleEl ) {
cardPickerEditorSubtitleEl . value = parsedText . secondary || "" ;
}
if ( cardPickerEditorColorEl ) {
const backgroundColor = normalizeCustomCardBackgroundColor ( card ? . backgroundColor );
cardPickerEditorColorEl . value = backgroundColor || "#164e63" ;
cardPickerEditorColorEl . dataset . explicit = backgroundColor ? "true" : "false" ;
}
state . cardPicker . editorImageData = normalizeCustomCardBackgroundImage ( card ? . backgroundImage );
if ( cardPickerEditorImageUploadEl ) {
cardPickerEditorImageUploadEl . value = "" ;
}
if ( cardPickerEditorRemoveEl ) {
cardPickerEditorRemoveEl . hidden = ! card ;
}
updateCustomCardEditorPreview ();
}
function getCustomCardEditorDraft () {
const title = normalizeLabelText ( cardPickerEditorTitleEl ? . value || "" );
const subtitle = normalizeLabelText ( cardPickerEditorSubtitleEl ? . value || "" );
const customText = normalizeCustomCardText ([ title , subtitle ]. filter ( Boolean ). join ( "\n" ));
const backgroundColor = cardPickerEditorColorEl ? . dataset . explicit === "true"
? normalizeCustomCardBackgroundColor ( cardPickerEditorColorEl ? . value || "" )
: "" ;
const backgroundImage = normalizeCustomCardBackgroundImage ( state . cardPicker . editorImageData );
return {
title ,
subtitle ,
customText ,
backgroundColor ,
backgroundImage
};
}
function updateCustomCardEditorPreview () {
if ( ! ( cardPickerEditorPreviewEl instanceof HTMLElement )) {
return ;
}
const draft = getCustomCardEditorDraft ();
const previewCard = createCustomFrameCardRecord ( draft , ` ${ FRAME_CUSTOM_CARD_PREFIX } preview` );
cardPickerEditorPreviewEl . replaceChildren ();
if ( ! previewCard ) {
const emptyPreviewEl = document . createElement ( "div" );
emptyPreviewEl . className = "tarot-frame-card-picker-editor-empty" ;
emptyPreviewEl . textContent = "Add text, a color, or an image to build the card preview." ;
cardPickerEditorPreviewEl . appendChild ( emptyPreviewEl );
if ( cardPickerEditorSaveEl ) {
cardPickerEditorSaveEl . disabled = true ;
}
if ( cardPickerEditorImageClearEl ) {
cardPickerEditorImageClearEl . disabled = ! draft . backgroundImage ;
}
if ( cardPickerEditorImageEl ) {
cardPickerEditorImageEl . textContent = draft . backgroundImage ? "Uploaded image ready." : "No image selected." ;
}
return ;
}
if ( cardPickerEditorSaveEl ) {
cardPickerEditorSaveEl . disabled = false ;
}
if ( cardPickerEditorImageClearEl ) {
cardPickerEditorImageClearEl . disabled = ! draft . backgroundImage ;
}
if ( cardPickerEditorImageEl ) {
cardPickerEditorImageEl . textContent = draft . backgroundImage ? "Uploaded image ready." : "No image selected." ;
}
cardPickerEditorPreviewEl . appendChild ( createCardTextFaceElement ( buildCardTextFaceModel ( previewCard )));
}
function openCustomCardEditor ( slotId , existingCardId = "" , options = {}) {
const targetSlotId = String ( slotId || "" ). trim ();
if ( ! isValidSlotId ( targetSlotId )) {
return false ;
}
createCustomCardEditorElements ();
closeCardInsertMenu ();
closeCardPicker ();
state . cardPicker . slotId = targetSlotId ;
state . cardPicker . editingCardId = String ( existingCardId || "" ). trim ();
const existingCard = state . cardPicker . editingCardId ? state . customCards . get ( state . cardPicker . editingCardId ) || null : null ;
if ( cardCustomEditorTitleEl ) {
cardCustomEditorTitleEl . textContent = ` ${ existingCard ? "Edit" : "Create" } Custom Card at ${ describeSlot ( targetSlotId ) } ` ;
}
populateCustomCardEditor ( existingCard );
if ( ! cardCustomEditorEl || cardCustomEditorEl . hidden || options . reposition ) {
const anchor = getCardPickerAnchor ( targetSlotId );
positionCustomCardEditor (
Number . isFinite ( options . anchorX ) ? options . anchorX : anchor . x ,
Number . isFinite ( options . anchorY ) ? options . anchorY : anchor . y
);
} else {
cardCustomEditorEl . hidden = false ;
}
requestAnimationFrame (() => {
cardPickerEditorTitleEl ? . focus ({ preventScroll : true });
cardPickerEditorTitleEl ? . select ? .();
});
return true ;
}
function placeCustomCardInSlot ( slotId , customCardInput , existingCardId = "" ) {
const targetSlotId = String ( slotId || "" ). trim ();
if ( ! isValidSlotId ( targetSlotId )) {
return false ;
}
const nextCard = createCustomFrameCardRecord ( customCardInput , existingCardId || createCustomFrameCardId ());
if ( ! nextCard ) {
return false ;
}
state . customCards . set ( nextCard . id , nextCard );
const previousSlotId = findAssignedSlotIdByCardId ( nextCard . id );
2026-04-13 14:28:03 -07:00
const inheritedFlip = previousSlotId && previousSlotId !== targetSlotId ? isSlotFlipped ( previousSlotId ) : isSlotFlipped ( targetSlotId );
2026-04-07 03:16:37 -07:00
if ( previousSlotId && previousSlotId !== targetSlotId ) {
2026-04-13 14:28:03 -07:00
clearSlotAssignmentState ( previousSlotId );
2026-04-07 03:16:37 -07:00
}
2026-04-13 14:28:03 -07:00
assignCardToSlot (
targetSlotId ,
nextCard . id ,
inheritedFlip
);
2026-04-07 03:16:37 -07:00
pruneUnusedCustomCards ();
state . layoutReady = true ;
render ({ preserveViewport : true });
syncControls ();
setStatus ( ` ${ existingCardId ? "Custom card updated" : "Custom card placed" } at ${ describeSlot ( targetSlotId ) } .` );
return true ;
}
function submitCustomCardEditor () {
const targetSlotId = String ( state . cardPicker . slotId || "" ). trim ();
if ( ! isValidSlotId ( targetSlotId )) {
return false ;
}
const draft = getCustomCardEditorDraft ();
if ( ! draft . customText && ! draft . backgroundColor && ! draft . backgroundImage ) {
window . alert ( "Add text, a color, or an image before saving this custom card." );
return false ;
}
return placeCustomCardInSlot ( targetSlotId , draft , state . cardPicker . editingCardId );
}
function removeEditedCustomCard () {
const targetSlotId = String ( state . cardPicker . slotId || "" ). trim ();
const targetCardId = String ( state . cardPicker . editingCardId || "" ). trim ();
if ( ! isValidSlotId ( targetSlotId ) || ! targetCardId ) {
return false ;
}
2026-04-13 14:28:03 -07:00
clearSlotAssignmentState ( targetSlotId );
2026-04-07 03:16:37 -07:00
state . customCards . delete ( targetCardId );
pruneUnusedCustomCards ();
state . layoutReady = true ;
render ({ preserveViewport : true });
syncControls ();
setStatus ( `Custom card removed from ${ describeSlot ( targetSlotId ) } .` );
return true ;
}
function editCustomCard ( slotId , cardId ) {
const targetSlotId = String ( slotId || "" ). trim ();
const targetCardId = String ( cardId || "" ). trim ();
const existingCard = state . customCards . get ( targetCardId ) || null ;
if ( ! isValidSlotId ( targetSlotId ) || ! existingCard ) {
return false ;
}
return openCustomCardEditor ( targetSlotId , targetCardId , { reposition : true });
}
2026-04-04 03:39:29 -07:00
function buildCardPickerSections () {
const cards = getCards ();
const queryTerms = normalizeKey ( state . cardPicker . query ). split ( /\s+/ ). filter ( Boolean );
const matchesQuery = ( card ) => {
const haystack = buildCardPickerSearchText ( card );
2026-04-07 03:16:37 -07:00
return matchesCardPickerTerms ( queryTerms , haystack );
2026-04-04 03:39:29 -07:00
};
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" ;
2026-04-07 03:16:37 -07:00
emptyEl . textContent = "No stock tarot cards match that search." ;
2026-04-04 03:39:29 -07:00
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" ;
2026-04-07 03:16:37 -07:00
group . items . forEach (( item ) => {
2026-04-04 03:39:29 -07:00
const buttonEl = document . createElement ( "button" );
buttonEl . type = "button" ;
buttonEl . className = "tarot-frame-card-picker-option" ;
2026-04-07 03:16:37 -07:00
buttonEl . dataset . cardId = getCardId ( item );
2026-04-04 03:39:29 -07:00
const titleEl = document . createElement ( "strong" );
2026-04-07 03:16:37 -07:00
titleEl . textContent = getDisplayCardName ( item );
2026-04-04 03:39:29 -07:00
const metaEl = document . createElement ( "span" );
2026-04-07 03:16:37 -07:00
metaEl . textContent = item ? . arcana === "Major"
? `Trump ${ Number . isFinite ( Number ( item ? . number )) ? Number ( item . number ) : "" } ` . trim ()
: [ normalizeLabelText ( item ? . rank ), normalizeLabelText ( item ? . suit )]. filter ( Boolean ). join ( " · " );
2026-04-04 03:39:29 -07:00
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 ();
2026-04-07 03:16:37 -07:00
closeCardInsertMenu ();
2026-04-04 03:39:29 -07:00
state . cardPicker . open = true ;
state . cardPicker . slotId = String ( slotId || "" ). trim ();
2026-04-07 03:16:37 -07:00
state . cardPicker . editingCardId = "" ;
state . cardPicker . editorImageData = "" ;
2026-04-04 03:39:29 -07:00
if ( cardPickerSearchEl ) {
cardPickerSearchEl . value = state . cardPicker . query ;
}
2026-04-07 03:16:37 -07:00
setCardPickerMode ( "browse" );
2026-04-04 03:39:29 -07:00
positionCardPicker ( anchorX , anchorY );
}
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 );
2026-04-13 14:28:03 -07:00
const inheritedFlip = previousSlotId && previousSlotId !== targetSlotId ? isSlotFlipped ( previousSlotId ) : isSlotFlipped ( targetSlotId );
2026-04-04 03:39:29 -07:00
if ( previousSlotId && previousSlotId !== targetSlotId ) {
2026-04-13 14:28:03 -07:00
clearSlotAssignmentState ( previousSlotId );
2026-04-04 03:39:29 -07:00
}
2026-04-13 14:28:03 -07:00
assignCardToSlot (
targetSlotId ,
targetCardId ,
inheritedFlip
);
2026-04-07 03:16:37 -07:00
pruneUnusedCustomCards ();
2026-04-04 03:39:29 -07:00
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 ();
2026-04-13 14:28:03 -07:00
state . slotFlips . clear ();
2026-04-07 03:16:37 -07:00
state . customCards . clear ();
2026-04-13 14:28:03 -07:00
state . selectedSlotIds . clear ();
2026-04-04 03:39:29 -07:00
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 ;
}
2026-04-13 14:28:03 -07:00
function scheduleLongPress ( slotId , event , action = "insert" ) {
2026-04-04 03:39:29 -07:00
clearLongPressGesture ();
document . addEventListener ( "pointermove" , handleLongPressPointerMove );
document . addEventListener ( "pointerup" , handleLongPressPointerUp );
document . addEventListener ( "pointercancel" , handleLongPressPointerCancel );
state . longPress = {
pointerId : event . pointerId ,
slotId ,
2026-04-13 14:28:03 -07:00
action ,
2026-04-04 03:39:29 -07:00
startX : event . clientX ,
startY : event . clientY ,
timerId : window . setTimeout (() => {
const activeGesture = state . longPress ;
clearLongPressGesture ();
if ( ! activeGesture ) {
return ;
}
state . suppressClick = true ;
2026-04-13 14:28:03 -07:00
cleanupDrag ();
if ( activeGesture . action === "card" ) {
const cardId = String ( state . slotAssignments . get ( String ( activeGesture . slotId || "" ). trim ()) || "" ). trim ();
if ( cardId ) {
openCardActionMenu ( activeGesture . slotId , cardId , activeGesture . startX , activeGesture . startY , { focusFirstButton : false , fromTouchLongPress : true });
}
return ;
}
2026-04-07 03:16:37 -07:00
openCardInsertMenu ( activeGesture . slotId , activeGesture . startX , activeGesture . startY );
2026-04-04 03:39:29 -07:00
}, 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 ();
}
function handleLongPressPointerMove ( event ) {
updateLongPress ( event );
}
function handleLongPressPointerUp ( event ) {
finishLongPress ( event );
}
function handleLongPressPointerCancel ( event ) {
finishLongPress ( event );
}
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
};
}
function getTouchDistance ( touches ) {
if ( ! touches || touches . length < 2 ) {
return 0 ;
}
const first = touches [ 0 ];
const second = touches [ 1 ];
if ( ! first || ! second ) {
return 0 ;
}
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 ? .();
}
}
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 ();
}
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-04 12:15:52 -07:00
removeOrphanedDragGhosts ();
2026-04-04 03:39:29 -07:00
clearLongPressGesture ();
if ( state . drag ) {
cleanupDrag ();
}
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 ;
}
state . panGesture = null ;
document . removeEventListener ( "pointermove" , handlePanPointerMove );
document . removeEventListener ( "pointerup" , handlePanPointerUp );
document . removeEventListener ( "pointercancel" , handlePanPointerCancel );
syncActiveTouchGestureCapture ();
updateViewportInteractionState ();
}
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 ();
}
function handlePanPointerUp ( event ) {
if ( ! state . panGesture || state . panGesture . source !== "pointer" || event . pointerId !== state . panGesture . pointerId ) {
return ;
}
finishPanGesture ();
}
function handlePanPointerCancel ( event ) {
if ( ! state . panGesture || state . panGesture . source !== "pointer" || event . pointerId !== state . panGesture . pointerId ) {
return ;
}
finishPanGesture ();
}
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 );
}
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-13 14:28:03 -07:00
function isEditableTarget ( target ) {
if ( ! ( target instanceof Element )) {
return false ;
}
return Boolean ( target . closest ( "input, textarea, select, [contenteditable=''], [contenteditable='true']" ));
}
function getGridViewportCenterAnchor () {
const viewportEl = getGridViewportElement ();
if ( ! ( viewportEl instanceof HTMLElement )) {
return null ;
}
const rect = viewportEl . getBoundingClientRect ();
if ( ! ( rect . width > 0 && rect . height > 0 )) {
return null ;
}
return {
anchorClientX : rect . left + ( rect . width / 2 ),
anchorClientY : rect . top + ( rect . height / 2 )
};
}
function normalizeWheelDelta ( event ) {
const mode = Number ( event ? . deltaMode ) || 0 ;
const multiplier = mode === 1 ? 16 : mode === 2 ? Math . max ( 1 , window . innerHeight || 1 ) : 1 ;
return {
deltaX : ( Number ( event ? . deltaX ) || 0 ) * multiplier ,
deltaY : ( Number ( event ? . deltaY ) || 0 ) * multiplier
};
}
function handleBoardWheel ( event ) {
if ( state . exportInProgress ) {
return ;
}
const viewportEl = getGridViewportElement ();
if ( ! ( viewportEl instanceof HTMLElement )) {
return ;
}
const deltas = normalizeWheelDelta ( event );
if ( event . ctrlKey ) {
event . preventDefault ();
const centerAnchor = getGridViewportCenterAnchor ();
const anchorClientX = Number . isFinite ( Number ( event . clientX )) ? Number ( event . clientX ) : centerAnchor ? . anchorClientX ;
const anchorClientY = Number . isFinite ( Number ( event . clientY )) ? Number ( event . clientY ) : centerAnchor ? . anchorClientY ;
const zoomFactor = Math . exp ( - ( deltas . deltaY || 0 ) * 0.002 );
const nextScale = clampFrameGridZoomScale ( getGridZoomScale () * zoomFactor );
setGridZoomScale ( nextScale , {
preserveViewport : false ,
anchorClientX ,
anchorClientY ,
statusMessage : ""
});
state . suppressClick = true ;
return ;
}
if ( ! ( Math . abs ( deltas . deltaX ) > 0 || Math . abs ( deltas . deltaY ) > 0 )) {
return ;
}
event . preventDefault ();
viewportEl . scrollLeft += deltas . deltaX ;
viewportEl . scrollTop += deltas . deltaY ;
state . suppressClick = true ;
}
function getFrameGridZoomShortcutDirection ( event ) {
if ( ! ( event instanceof KeyboardEvent )) {
return 0 ;
}
const key = String ( event . key || "" );
const code = String ( event . code || "" );
if ( key === "+" || key === "=" || code === "NumpadAdd" ) {
return 1 ;
}
if ( key === "-" || key === "_" || code === "NumpadSubtract" ) {
return - 1 ;
}
return 0 ;
}
function shouldHandleFrameZoomShortcut ( event ) {
const { tarotFrameSectionEl } = getElements ();
if ( ! ( tarotFrameSectionEl instanceof HTMLElement ) || tarotFrameSectionEl . hidden ) {
return false ;
}
if ( state . cardInsertMenu . open || state . cardPicker . open || ( cardCustomEditorEl && ! cardCustomEditorEl . hidden )) {
return false ;
}
return ! isEditableTarget ( event ? . target );
}
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 : [];
}
2026-04-07 03:16:37 -07:00
function isCustomFrameCardId ( cardId ) {
return String ( cardId || "" ). trim (). startsWith ( FRAME_CUSTOM_CARD_PREFIX );
}
function isCustomFrameCard ( card ) {
return Boolean ( card ) && isCustomFrameCardId ( getCardId ( card ));
}
function normalizeCustomCardText ( value ) {
return String ( value || "" )
. replace ( /\r/g , "" )
. split ( "\n" )
. map (( line ) => String ( line || "" ). trim ())
. filter ( Boolean )
. join ( "\n" )
. trim ();
}
function normalizeCustomCardBackgroundColor ( value ) {
const normalized = String ( value || "" ). trim ();
return /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/ . test ( normalized ) ? normalized : "" ;
}
function normalizeCustomCardBackgroundImage ( value ) {
return String ( value || "" ). trim ();
}
function parseCustomCardText ( value ) {
const normalized = normalizeCustomCardText ( value );
if ( ! normalized ) {
return {
raw : "" ,
primary : "" ,
secondary : ""
};
}
const separator = normalized . includes ( "|" ) ? "|" : "\n" ;
const segments = normalized
. split ( separator )
. map (( segment ) => normalizeLabelText ( segment ))
. filter ( Boolean );
return {
raw : normalized ,
primary : segments [ 0 ] || "Label" ,
secondary : segments . slice ( 1 ). join ( " · " )
};
}
function buildCustomLabelFaceClassName ( parsedText , hasVisual = false ) {
const primaryLength = String ( parsedText ? . primary || "" ). replace ( /\s+/g , "" ). length ;
const secondaryLength = String ( parsedText ? . secondary || "" ). replace ( /\s+/g , "" ). length ;
const classes = [ "is-custom-label" ];
if ( hasVisual ) {
classes . push ( "is-custom-visual" );
}
if ( ! primaryLength && ! secondaryLength ) {
classes . push ( "is-custom-blank" );
if ( hasVisual ) {
classes . push ( "is-custom-visual-only" );
}
return classes . join ( " " );
}
if ( ! secondaryLength && primaryLength <= 4 ) {
classes . push ( "is-custom-hero" );
} else if ( ! secondaryLength && primaryLength <= 10 ) {
classes . push ( "is-custom-short" );
} else if ( primaryLength <= 14 && secondaryLength <= 28 ) {
classes . push ( "is-custom-medium" );
}
if ( primaryLength >= 18 || secondaryLength >= 34 ) {
classes . push ( "is-dense" );
}
return classes . join ( " " );
}
function createCustomFrameCardId () {
return ` ${ FRAME_CUSTOM_CARD_PREFIX }${ Date . now (). toString ( 36 ) } - ${ Math . random (). toString ( 36 ). slice ( 2 , 8 ) } ` ;
}
function createCustomFrameCardRecord ( input , id = createCustomFrameCardId ()) {
const customText = typeof input === "string"
? normalizeCustomCardText ( input )
: normalizeCustomCardText ( input ? . customText || [ input ? . title , input ? . subtitle ]. filter ( Boolean ). join ( "\n" ));
const backgroundColor = typeof input === "string" ? "" : normalizeCustomCardBackgroundColor ( input ? . backgroundColor );
const backgroundImage = typeof input === "string" ? "" : normalizeCustomCardBackgroundImage ( input ? . backgroundImage );
const parsed = parseCustomCardText ( customText );
if ( ! parsed . raw && ! backgroundColor && ! backgroundImage ) {
return null ;
}
return {
id : isCustomFrameCardId ( id ) ? id : createCustomFrameCardId (),
frameCardType : "custom-text" ,
arcana : "Custom" ,
name : parsed . primary || "Custom Card" ,
customText : parsed . raw ,
summary : parsed . secondary || "" ,
backgroundColor ,
backgroundImage
};
}
function normalizeSavedCustomCardRecord ( rawCard ) {
const cardId = String ( rawCard ? . id || "" ). trim ();
const text = normalizeCustomCardText ( rawCard ? . customText || rawCard ? . text || rawCard ? . name );
const backgroundColor = normalizeCustomCardBackgroundColor ( rawCard ? . backgroundColor );
const backgroundImage = normalizeCustomCardBackgroundImage ( rawCard ? . backgroundImage );
if ( ! text && ! backgroundColor && ! backgroundImage ) {
return null ;
}
return createCustomFrameCardRecord ({
customText : text ,
backgroundColor ,
backgroundImage
}, cardId );
}
2026-04-01 12:31:56 -07:00
function getCardId ( card ) {
return String ( card ? . id || "" ). trim ();
}
function getCardMap ( cards ) {
2026-04-07 03:16:37 -07:00
const map = new Map ( cards . map (( card ) => [ getCardId ( card ), card ]));
state . customCards . forEach (( card , cardId ) => {
if ( cardId ) {
map . set ( cardId , card );
}
});
return map ;
}
function removeCardFromSlot ( slotId ) {
const targetSlotId = String ( slotId || "" ). trim ();
if ( ! isValidSlotId ( targetSlotId )) {
return null ;
}
const cardId = String ( state . slotAssignments . get ( targetSlotId ) || "" ). trim ();
if ( ! cardId ) {
return null ;
}
const card = getCardMap ( getCards ()). get ( cardId ) || null ;
2026-04-13 14:28:03 -07:00
clearSlotAssignmentState ( targetSlotId );
state . selectedSlotIds . delete ( targetSlotId );
2026-04-07 03:16:37 -07:00
if ( isCustomFrameCardId ( cardId )) {
state . customCards . delete ( cardId );
}
pruneUnusedCustomCards ();
state . layoutReady = true ;
return card ;
}
function pruneUnusedCustomCards () {
const assignedCustomIds = new Set (
Array . from ( state . slotAssignments . values ())
. map (( cardId ) => String ( cardId || "" ). trim ())
. filter (( cardId ) => isCustomFrameCardId ( cardId ))
);
Array . from ( state . customCards . keys ()). forEach (( cardId ) => {
if ( ! assignedCustomIds . has ( cardId )) {
state . customCards . delete ( cardId );
}
});
2026-04-01 12:31:56 -07:00
}
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 "" ;
}
2026-04-07 03:16:37 -07:00
if ( isCustomFrameCard ( card )) {
return "" ;
}
2026-04-01 12:31:56 -07:00
const deckOptions = resolveDeckOptions ( card ) || undefined ;
return String (
tarotCardImages . resolveTarotCardThumbnail ? .( card . name , deckOptions )
|| tarotCardImages . resolveTarotCardImage ? .( card . name , deckOptions )
|| ""
). trim ();
}
function getDisplayCardName ( card ) {
2026-04-07 03:16:37 -07:00
if ( isCustomFrameCard ( card )) {
return parseCustomCardText ( card ? . customText ). primary || "Custom Card" ;
}
2026-04-01 12:31:56 -07:00
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 ;
}
2026-04-07 03:16:37 -07:00
if ( isCustomFrameCard ( card )) {
return null ;
}
2026-04-01 16:08:52 -07:00
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 ;
}
2026-04-07 03:16:37 -07:00
if ( isCustomFrameCard ( card )) {
return false ;
}
2026-04-01 16:08:52 -07:00
if ( card . arcana === "Major" ) {
return config . getHouseTopCardsVisible ? .() !== false ;
}
return config . getHouseBottomCardsVisible ? .() !== false ;
}
function buildCardTextFaceModel ( card ) {
2026-04-07 03:16:37 -07:00
if ( isCustomFrameCard ( card )) {
const customText = parseCustomCardText ( card ? . customText );
const backgroundColor = normalizeCustomCardBackgroundColor ( card ? . backgroundColor );
const backgroundImage = normalizeCustomCardBackgroundImage ( card ? . backgroundImage );
return {
primary : customText . primary || "" ,
secondary : customText . secondary ,
className : buildCustomLabelFaceClassName ( customText , Boolean ( backgroundColor || backgroundImage )),
backgroundColor ,
backgroundImage
};
}
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 || ""
};
}
return {
primary : displayName || "Tarot" ,
secondary : "" ,
className : ""
};
}
function getCardOverlayDate ( card ) {
2026-04-01 12:31:56 -07:00
const court = getRelation ( card , "courtDateWindow" ) ? . data || null ;
if ( court ? . dateStart && court ? . dateEnd ) {
return formatDateRange ( court . dateStart , court . dateEnd );
}
2026-04-01 16:08:52 -07:00
const decan = getRelation ( card , "decan" ) ? . data || null ;
if ( decan ? . dateStart && decan ? . dateEnd ) {
return formatDateRange ( decan . dateStart , decan . dateEnd );
}
2026-04-01 12:31:56 -07:00
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 } ` ;
}
2026-04-13 14:28:03 -07:00
function isSlotSelected ( slotId ) {
return state . selectedSlotIds . has ( String ( slotId || "" ). trim ());
}
function getSelectedSlotIds () {
return Array . from ( state . selectedSlotIds )
. map (( slotId ) => String ( slotId || "" ). trim ())
. filter (( slotId ) => isValidSlotId ( slotId ) && String ( state . slotAssignments . get ( slotId ) || "" ). trim ())
. sort ( compareSlotIds );
}
function setSelectedSlotIds ( slotIds ) {
const nextSlotIds = Array . from ( new Set (
( Array . isArray ( slotIds ) ? slotIds : [])
. map (( slotId ) => String ( slotId || "" ). trim ())
. filter (( slotId ) => isValidSlotId ( slotId ) && String ( state . slotAssignments . get ( slotId ) || "" ). trim ())
)). sort ( compareSlotIds );
const changed = nextSlotIds . length !== state . selectedSlotIds . size
|| nextSlotIds . some (( slotId ) => ! state . selectedSlotIds . has ( slotId ));
state . selectedSlotIds = new Set ( nextSlotIds );
syncSelectionChip ();
return {
changed ,
slotIds : nextSlotIds
};
}
function buildSelectionStatusMessage ( count = getSelectedSlotIds (). length ) {
if ( ! ( count > 0 )) {
return "Card selection cleared." ;
}
return ` ${ count } card ${ count === 1 ? "" : "s" } selected. Drag any selected card to move ${ count === 1 ? "it" : "them" } together. Ctrl/Cmd-click adjusts the set on desktop, and touch users can long-press a card for Select Multiple.` ;
}
function getDragSourceSlotIds ( anchorSlotId ) {
const targetSlotId = String ( anchorSlotId || "" ). trim ();
if ( ! isValidSlotId ( targetSlotId )) {
return [];
}
const selectedSlotIds = getSelectedSlotIds ();
if ( selectedSlotIds . length > 1 && selectedSlotIds . includes ( targetSlotId )) {
return selectedSlotIds ;
}
return [ targetSlotId ];
}
function isSlotFlipped ( slotId ) {
const targetSlotId = String ( slotId || "" ). trim ();
return Boolean ( targetSlotId && state . slotFlips . get ( targetSlotId ));
}
function setSlotFlipState ( slotId , isFlipped ) {
const targetSlotId = String ( slotId || "" ). trim ();
if ( ! isValidSlotId ( targetSlotId )) {
return ;
}
if ( isFlipped ) {
state . slotFlips . set ( targetSlotId , true );
return ;
}
state . slotFlips . delete ( targetSlotId );
}
function clearSlotAssignmentState ( slotId ) {
const targetSlotId = String ( slotId || "" ). trim ();
if ( ! isValidSlotId ( targetSlotId )) {
return ;
}
state . slotAssignments . delete ( targetSlotId );
state . slotFlips . delete ( targetSlotId );
}
function assignCardToSlot ( slotId , cardId , isFlipped = false ) {
const targetSlotId = String ( slotId || "" ). trim ();
const targetCardId = String ( cardId || "" ). trim ();
if ( ! isValidSlotId ( targetSlotId ) || ! targetCardId ) {
return false ;
}
state . slotAssignments . set ( targetSlotId , targetCardId );
setSlotFlipState ( targetSlotId , isFlipped );
return true ;
}
function moveSlotAssignment ( sourceSlotId , targetSlotId ) {
const fromSlotId = String ( sourceSlotId || "" ). trim ();
const toSlotId = String ( targetSlotId || "" ). trim ();
if ( ! isValidSlotId ( fromSlotId ) || ! isValidSlotId ( toSlotId )) {
return false ;
}
const cardId = String ( state . slotAssignments . get ( fromSlotId ) || "" ). trim ();
if ( ! cardId ) {
return false ;
}
const isFlipped = isSlotFlipped ( fromSlotId );
clearSlotAssignmentState ( fromSlotId );
assignCardToSlot ( toSlotId , cardId , isFlipped );
return true ;
}
function swapSlotAssignments ( sourceSlotId , targetSlotId ) {
const fromSlotId = String ( sourceSlotId || "" ). trim ();
const toSlotId = String ( targetSlotId || "" ). trim ();
if ( ! isValidSlotId ( fromSlotId ) || ! isValidSlotId ( toSlotId )) {
return false ;
}
const sourceCardId = String ( state . slotAssignments . get ( fromSlotId ) || "" ). trim ();
const targetCardId = String ( state . slotAssignments . get ( toSlotId ) || "" ). trim ();
if ( ! sourceCardId ) {
return false ;
}
const sourceFlip = isSlotFlipped ( fromSlotId );
const targetFlip = isSlotFlipped ( toSlotId );
state . slotAssignments . set ( toSlotId , sourceCardId );
setSlotFlipState ( toSlotId , sourceFlip );
if ( targetCardId ) {
state . slotAssignments . set ( fromSlotId , targetCardId );
setSlotFlipState ( fromSlotId , targetFlip );
} else {
clearSlotAssignmentState ( fromSlotId );
}
return true ;
}
function toggleCardFlip ( slotId ) {
const targetSlotId = String ( slotId || "" ). trim ();
if ( ! isValidSlotId ( targetSlotId )) {
return false ;
}
const cardId = String ( state . slotAssignments . get ( targetSlotId ) || "" ). trim ();
if ( ! cardId ) {
return false ;
}
const nextIsFlipped = ! isSlotFlipped ( targetSlotId );
setSlotFlipState ( targetSlotId , nextIsFlipped );
state . layoutReady = true ;
const card = getCardMap ( getCards ()). get ( cardId ) || null ;
setStatus ( ` ${ getDisplayCardName ( card ) } flipped ${ nextIsFlipped ? "upside down" : "upright" } at ${ describeSlot ( targetSlotId ) } .` );
return true ;
}
2026-04-01 12:31:56 -07:00
function setStatus ( message ) {
state . statusMessage = String ( message || "" ). trim ();
const { tarotFrameStatusEl } = getElements ();
if ( tarotFrameStatusEl ) {
tarotFrameStatusEl . textContent = state . statusMessage ;
}
}
2026-04-01 16:08:52 -07:00
function applyLayoutPreset ( layoutId = state . currentLayoutId , cards = getCards (), nextStatusMessage = "" ) {
2026-04-04 03:39:29 -07:00
const layoutPreset = getLayoutPreset ( layoutId ) || LAYOUT_PRESETS [ 0 ];
2026-04-01 16:08:52 -07:00
state . currentLayoutId = layoutPreset . id ;
2026-04-01 12:31:56 -07:00
state . slotAssignments . clear ();
2026-04-13 14:28:03 -07:00
state . slotFlips . clear ();
state . selectedSlotIds . clear ();
2026-04-07 03:16:37 -07:00
state . customCards . clear ();
2026-04-01 12:31:56 -07:00
2026-04-01 16:08:52 -07:00
layoutPreset . buildPlacements ( cards ). forEach (( placement ) => {
2026-04-13 14:28:03 -07:00
assignCardToSlot ( getSlotId ( placement . row , placement . column ), placement . cardId );
2026-04-01 12:31:56 -07:00
});
state . layoutReady = true ;
2026-04-04 03:39:29 -07:00
persistActiveLayoutId ( layoutPreset . id );
2026-04-01 16:08:52 -07:00
setStatus ( nextStatusMessage || layoutPreset . statusMessage || buildReadyStatus ( cards ));
}
2026-04-04 03:39:29 -07:00
function applySavedLayout ( layoutId = state . currentLayoutId , cards = getCards (), nextStatusMessage = "" ) {
const savedLayout = getSavedLayout ( layoutId );
if ( ! savedLayout ) {
applyLayoutPreset ( "frames" , cards , nextStatusMessage );
return ;
}
2026-04-07 03:16:37 -07:00
state . customCards = new Map (( savedLayout . customCards || []). map (( card ) => [ getCardId ( card ), card ]));
2026-04-04 03:39:29 -07:00
const cardMap = getCardMap ( cards );
state . currentLayoutId = savedLayout . id ;
state . slotAssignments . clear ();
2026-04-13 14:28:03 -07:00
state . slotFlips . clear ();
state . selectedSlotIds . clear ();
2026-04-04 03:39:29 -07:00
savedLayout . slotAssignments . forEach (( entry ) => {
if ( cardMap . has ( entry . cardId )) {
2026-04-13 14:28:03 -07:00
assignCardToSlot ( entry . slotId , entry . cardId , Boolean ( entry . isFlipped ));
2026-04-04 03:39:29 -07:00
}
});
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 );
}
2026-04-01 16:08:52 -07:00
function resetLayout ( cards = getCards (), nextStatusMessage = "" ) {
2026-04-04 03:39:29 -07:00
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" ;
2026-04-07 03:16:37 -07:00
const customLabelCount = Array . isArray ( layout ? . customCards ) ? layout . customCards . length : 0 ;
const customLabelText = customLabelCount ? ` · ${ customLabelCount } label ${ customLabelCount === 1 ? "" : "s" } ` : "" ;
return ` ${ layout ? . slotAssignments ? . length || 0 } saved slots ${ customLabelText } · ${ zoomLabel } · ${ infoLabel } · ${ noteLabel } ` ;
2026-04-04 03:39:29 -07:00
}
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 );
2026-04-06 20:22:31 -07:00
const guideToggleEl = document . createElement ( "label" );
guideToggleEl . className = "tarot-frame-toggle" ;
guideToggleEl . setAttribute ( "for" , "tarot-frame-layout-guide-visible" );
const guideToggleInputEl = document . createElement ( "input" );
guideToggleInputEl . id = "tarot-frame-layout-guide-visible" ;
guideToggleInputEl . type = "checkbox" ;
guideToggleInputEl . checked = Boolean ( state . layoutGuideVisible );
guideToggleInputEl . disabled = Boolean ( state . exportInProgress );
guideToggleInputEl . dataset . layoutGuideVisible = "true" ;
const guideToggleLabelEl = document . createElement ( "span" );
guideToggleLabelEl . textContent = "Show layout guide + notes" ;
guideToggleEl . append ( guideToggleInputEl , guideToggleLabelEl );
tarotFrameLayoutPanelEl . appendChild ( guideToggleEl );
2026-04-04 03:39:29 -07:00
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 );
});
}
function saveCurrentLayout () {
const cards = getCards ();
if ( ! cards . length ) {
setStatus ( "Tarot cards are still loading..." );
return ;
}
const activeSavedLayout = getSavedLayout ( state . currentLayoutId );
const suggestedName = activeSavedLayout ? . label || "" ;
const inputName = window . prompt ( "Save current Tarot Frame layout as:" , suggestedName );
if ( inputName === null ) {
return ;
}
const label = normalizeLayoutLabel ( inputName );
if ( ! label ) {
setStatus ( "Layout save cancelled. Enter a name to save this arrangement." );
return ;
}
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 ;
}
}
const savedLayout = normalizeSavedLayoutRecord ({
id : existingLayout ? . id || activeSavedLayout ? . id || createSavedLayoutId (),
label ,
slotAssignments : captureSlotAssignmentsSnapshot ( cards ),
2026-04-07 03:16:37 -07:00
customCards : captureCustomCardsSnapshot (),
2026-04-04 03:39:29 -07:00
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.` );
}
function deleteSavedLayout ( layoutId ) {
const savedLayout = getSavedLayout ( layoutId );
if ( ! savedLayout ) {
return ;
}
const shouldDelete = window . confirm ( `Delete the saved layout \" ${ savedLayout . label } \" from this browser?` );
if ( ! shouldDelete ) {
return ;
}
state . customLayouts = state . customLayouts . filter (( layout ) => layout . id !== savedLayout . id );
delete state . layoutNotesById [ savedLayout . id ];
persistSavedLayouts ();
persistLayoutNotes ();
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 ;
}
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-07 03:16:37 -07:00
if ( isCustomFrameCard ( card )) {
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 } ` : "" } ` ;
2026-04-07 03:16:37 -07:00
const backgroundColor = normalizeCustomCardBackgroundColor ( faceModel ? . backgroundColor );
const backgroundImage = normalizeCustomCardBackgroundImage ( faceModel ? . backgroundImage );
2026-04-01 16:08:52 -07:00
2026-04-07 03:16:37 -07:00
if ( backgroundImage ) {
faceEl . classList . add ( "has-custom-image" );
faceEl . style . backgroundColor = backgroundColor || "" ;
faceEl . style . backgroundImage = `url(" ${ backgroundImage . replace ( /"/g , '\\"' ) } ")` ;
faceEl . style . backgroundSize = "cover" ;
faceEl . style . backgroundPosition = "center" ;
faceEl . style . backgroundRepeat = "no-repeat" ;
} else if ( backgroundColor ) {
faceEl . style . background = backgroundColor ;
faceEl . style . backgroundImage = "none" ;
}
if ( faceModel ? . primary ) {
const primaryEl = document . createElement ( "span" );
primaryEl . className = "tarot-frame-card-text-primary" ;
primaryEl . textContent = faceModel . primary ;
faceEl . appendChild ( primaryEl );
}
2026-04-01 16:08:52 -07:00
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 );
2026-04-13 14:28:03 -07:00
const flipped = isSlotFlipped ( slotId );
const selected = isSlotSelected ( slotId );
2026-04-01 12:31:56 -07:00
const slotEl = document . createElement ( "div" );
slotEl . className = "tarot-frame-slot" ;
slotEl . dataset . slotId = slotId ;
slotEl . style . gridRow = String ( row );
slotEl . style . gridColumn = String ( column );
2026-04-13 14:28:03 -07:00
if ( selected ) {
slotEl . classList . add ( "is-selected" );
}
if ( Array . isArray ( state . drag ? . sourceSlotIds ) && state . drag . sourceSlotIds . includes ( slotId )) {
2026-04-01 12:31:56 -07:00
slotEl . classList . add ( "is-drag-source" );
}
2026-04-13 14:28:03 -07:00
if ( Array . isArray ( state . drag ? . hoverSlotIds ) && state . drag . hoverSlotIds . includes ( slotId ) && state . drag ? . started ) {
2026-04-01 12:31:56 -07:00
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 ;
2026-04-13 14:28:03 -07:00
button . classList . toggle ( "is-flipped" , flipped );
button . classList . toggle ( "is-selected" , selected );
button . dataset . flipped = flipped ? "true" : "false" ;
2026-04-01 12:31:56 -07:00
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 );
2026-04-13 14:28:03 -07:00
button . setAttribute ( "aria-label" , ` ${ getDisplayCardName ( card ) } in row ${ row } , column ${ column }${ flipped ? ", flipped upside down" : "" }${ selected ? ", selected for group move" : "" } ` );
button . title = ` ${ getDisplayCardName ( card ) }${ flipped ? " (flipped upside down)" : "" } ` ;
2026-04-01 12:31:56 -07:00
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-24 00:14:10 -07:00
const imageCached = tarotCardImages . isImageLoaded ? .( imageSrc ) === true ;
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 );
2026-04-24 00:14:10 -07:00
image . loading = imageCached ? "eager" : "lazy" ;
2026-04-01 12:31:56 -07:00
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 );
2026-04-01 12:31:56 -07:00
2026-04-04 03:39:29 -07:00
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 ;
}
2026-04-13 14:28:03 -07:00
state . selectedSlotIds = new Set ( getSelectedSlotIds ());
syncSelectionChip ();
2026-04-04 03:39:29 -07:00
const preserveViewport = options . preserveViewport === true ;
const viewportSnapshot = preserveViewport ? captureGridViewportSnapshot () : null ;
const cards = getCards ();
const cardMap = getCardMap ( cards );
const layoutPreset = getLayoutDefinition ();
tarotFrameOverviewEl . replaceChildren ();
tarotFrameBoardEl . replaceChildren ();
tarotFrameOverviewEl . appendChild ( createOverview ( layoutPreset , cards ));
2026-04-06 20:22:31 -07:00
applyLayoutGuideUi ();
2026-04-04 03:39:29 -07:00
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-13 14:28:03 -07:00
tarotFrameSelectionChipEl ,
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 ();
2026-04-13 14:28:03 -07:00
if ( tarotFrameSelectionChipEl ) {
syncSelectionChip ();
}
2026-04-04 03:39:29 -07:00
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 } "]` );
}
2026-04-13 14:28:03 -07:00
function setHoverSlots ( slotIds ) {
const nextSlotIds = Array . isArray ( slotIds )
? Array . from ( new Set ( slotIds . map (( slotId ) => String ( slotId || "" ). trim ()). filter ( Boolean )))
: [];
const previousSlotIds = Array . isArray ( state . drag ? . hoverSlotIds ) ? state . drag . hoverSlotIds : [];
previousSlotIds . forEach (( slotId ) => {
if ( ! nextSlotIds . includes ( slotId )) {
getSlotElement ( slotId ) ? . classList . remove ( "is-drop-target" );
}
});
2026-04-01 12:31:56 -07:00
if ( state . drag ) {
2026-04-13 14:28:03 -07:00
state . drag . hoverSlotIds = nextSlotIds ;
state . drag . hoverSlotId = nextSlotIds [ 0 ] || "" ;
2026-04-01 12:31:56 -07:00
}
2026-04-13 14:28:03 -07:00
nextSlotIds . forEach (( slotId ) => {
2026-04-01 12:31:56 -07:00
getSlotElement ( slotId ) ? . classList . add ( "is-drop-target" );
2026-04-13 14:28:03 -07:00
});
2026-04-01 12:31:56 -07:00
}
2026-04-13 14:28:03 -07:00
function buildGroupDropPlan ( anchorSourceSlotId , anchorTargetSlotId , dragSlotIds ) {
const anchorSource = parseSlotId ( anchorSourceSlotId );
const anchorTarget = parseSlotId ( anchorTargetSlotId );
const normalizedSlotIds = Array . isArray ( dragSlotIds )
? dragSlotIds . map (( slotId ) => String ( slotId || "" ). trim ()). filter (( slotId ) => isValidSlotId ( slotId ))
: [];
if ( ! anchorSource || ! anchorTarget || ! normalizedSlotIds . length ) {
return {
valid : false ,
moved : false ,
targetSlotIds : [],
moves : [],
reason : ""
};
}
const sourceSet = new Set ( normalizedSlotIds );
const moves = [];
for ( const sourceSlotId of normalizedSlotIds ) {
const sourceSlot = parseSlotId ( sourceSlotId );
const cardId = String ( state . slotAssignments . get ( sourceSlotId ) || "" ). trim ();
if ( ! sourceSlot || ! cardId ) {
return {
valid : false ,
moved : false ,
targetSlotIds : [],
moves : [],
reason : ""
};
}
const targetRow = anchorTarget . row + ( sourceSlot . row - anchorSource . row );
const targetColumn = anchorTarget . column + ( sourceSlot . column - anchorSource . column );
if ( targetRow < 1 || targetRow > MASTER_GRID_SIZE || targetColumn < 1 || targetColumn > MASTER_GRID_SIZE ) {
return {
valid : false ,
moved : false ,
targetSlotIds : [],
moves : [],
reason : "Group move would push part of the selection outside the frame grid."
};
}
const targetSlotId = getSlotId ( targetRow , targetColumn );
const existingCardId = String ( state . slotAssignments . get ( targetSlotId ) || "" ). trim ();
if ( normalizedSlotIds . length > 1 && existingCardId && ! sourceSet . has ( targetSlotId )) {
return {
valid : false ,
moved : false ,
targetSlotIds : [],
moves : [],
reason : "Group move needs open destination slots for every selected card."
};
}
moves . push ({
sourceSlotId ,
targetSlotId ,
cardId ,
isFlipped : isSlotFlipped ( sourceSlotId )
});
}
const targetSlotIds = moves . map (( move ) => move . targetSlotId ). sort ( compareSlotIds );
return {
valid : new Set ( targetSlotIds ). size === targetSlotIds . length ,
moved : moves . some (( move ) => move . sourceSlotId !== move . targetSlotId ),
targetSlotIds ,
moves ,
reason : ""
};
}
function applyGroupMove ( plan ) {
if ( ! plan ? . valid || ! Array . isArray ( plan . moves ) || ! plan . moves . length ) {
return false ;
}
plan . moves . forEach (( move ) => {
clearSlotAssignmentState ( move . sourceSlotId );
});
plan . moves . forEach (( move ) => {
assignCardToSlot ( move . targetSlotId , move . cardId , move . isFlipped );
});
state . layoutReady = true ;
pruneUnusedCustomCards ();
return true ;
}
function createDragGhost ( card , options = {}) {
2026-04-01 12:31:56 -07:00
const ghost = document . createElement ( "div" );
ghost . className = "tarot-frame-drag-ghost" ;
2026-04-13 14:28:03 -07:00
const groupSize = Math . max ( 1 , Number ( options . groupSize ) || 1 );
const sourceSlotId = findAssignedSlotIdByCardId ( getCardId ( card ));
if ( isSlotFlipped ( sourceSlotId )) {
ghost . classList . add ( "is-flipped" );
}
if ( groupSize > 1 ) {
const countEl = document . createElement ( "span" );
countEl . className = "tarot-frame-drag-ghost-count" ;
countEl . textContent = String ( groupSize );
countEl . setAttribute ( "aria-label" , ` ${ groupSize } selected cards` );
ghost . appendChild ( countEl );
}
2026-04-01 12:31:56 -07:00
const imageSrc = resolveCardThumbnail ( card );
if ( imageSrc ) {
const image = document . createElement ( "img" );
image . src = imageSrc ;
image . alt = "" ;
ghost . appendChild ( image );
2026-04-07 03:16:37 -07:00
} else {
ghost . appendChild ( createCardTextFaceElement ( buildCardTextFaceModel ( card )));
2026-04-01 12:31:56 -07:00
}
if ( state . showInfo ) {
const label = document . createElement ( "span" );
label . className = "tarot-frame-drag-ghost-label" ;
label . textContent = getCardOverlayLabel ( card );
2026-04-07 03:16:37 -07:00
if ( label . textContent ) {
ghost . appendChild ( label );
}
2026-04-01 12:31:56 -07:00
}
document . body . appendChild ( ghost );
return ghost ;
}
2026-04-07 03:16:37 -07:00
function createDragTrashElement () {
if ( dragTrashEl instanceof HTMLElement ) {
return dragTrashEl ;
}
dragTrashEl = document . createElement ( "div" );
dragTrashEl . className = "tarot-frame-drag-trash" ;
dragTrashEl . dataset . frameDragTrash = "true" ;
dragTrashEl . hidden = true ;
const iconEl = document . createElement ( "span" );
iconEl . className = "tarot-frame-drag-trash-icon" ;
iconEl . setAttribute ( "aria-hidden" , "true" );
iconEl . innerHTML = "<svg viewBox='0 0 24 24' focusable='false' aria-hidden='true'><path d='M9 3h6l1 2h4v2H4V5h4l1-2Zm-1 6h2v8H8V9Zm6 0h2v8h-2V9ZM6 9h12l-1 11a2 2 0 0 1-2 2H9a2 2 0 0 1-2-2L6 9Z'/></svg>" ;
const copyEl = document . createElement ( "div" );
copyEl . className = "tarot-frame-drag-trash-copy" ;
const titleEl = document . createElement ( "strong" );
titleEl . textContent = "Trash" ;
const detailEl = document . createElement ( "span" );
detailEl . textContent = "Drop here to remove from the frame" ;
copyEl . append ( titleEl , detailEl );
dragTrashEl . append ( iconEl , copyEl );
document . body . appendChild ( dragTrashEl );
return dragTrashEl ;
}
function setDragTrashState ( visible , active = false ) {
const nextDragTrashEl = createDragTrashElement ();
nextDragTrashEl . hidden = ! visible ;
nextDragTrashEl . classList . toggle ( "is-active" , Boolean ( visible && active ));
}
2026-04-01 12:31:56 -07:00
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 );
2026-04-07 03:16:37 -07:00
const trashTarget = target instanceof Element ? target . closest ( "[data-frame-drag-trash='true']" ) : null ;
if ( state . drag ) {
state . drag . deleteActive = Boolean ( trashTarget );
setDragTrashState ( Boolean ( state . drag . started ), Boolean ( state . drag . deleteActive ));
}
if ( trashTarget ) {
2026-04-13 14:28:03 -07:00
if ( state . drag ) {
state . drag . dropPlan = null ;
state . drag . invalidDropMessage = "" ;
}
setHoverSlots ([]);
2026-04-07 03:16:37 -07:00
return ;
}
2026-04-01 12:31:56 -07:00
const slot = target instanceof Element ? target . closest ( ".tarot-frame-slot[data-slot-id]" ) : null ;
const nextSlotId = slot instanceof HTMLElement ? String ( slot . dataset . slotId || "" ) : "" ;
2026-04-13 14:28:03 -07:00
if ( ! state . drag ) {
return ;
}
const dragSlotIds = Array . isArray ( state . drag . sourceSlotIds ) && state . drag . sourceSlotIds . length
? state . drag . sourceSlotIds
: [ sourceSlotId ];
if ( dragSlotIds . length > 1 ) {
const plan = nextSlotId && nextSlotId !== sourceSlotId
? buildGroupDropPlan ( sourceSlotId , nextSlotId , dragSlotIds )
: null ;
state . drag . dropPlan = plan ? . valid ? plan : null ;
state . drag . invalidDropMessage = ! plan || plan . valid ? "" : plan . reason ;
setHoverSlots ( plan ? . valid && plan . moved ? plan . targetSlotIds : []);
return ;
}
state . drag . dropPlan = nextSlotId && nextSlotId !== sourceSlotId
? {
valid : true ,
moved : true ,
targetSlotIds : [ nextSlotId ],
moves : [],
reason : ""
}
: null ;
state . drag . invalidDropMessage = "" ;
setHoverSlots ( nextSlotId && nextSlotId !== sourceSlotId ? [ nextSlotId ] : []);
2026-04-01 12:31:56 -07:00
}
2026-04-04 12:15:52 -07:00
function removeOrphanedDragGhosts () {
document . querySelectorAll ( ".tarot-frame-drag-ghost" ). forEach (( ghostEl ) => {
if ( ghostEl instanceof HTMLElement ) {
ghostEl . remove ();
}
});
2026-04-07 03:16:37 -07:00
setDragTrashState ( false , false );
2026-04-04 12:15:52 -07:00
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-13 14:28:03 -07:00
setHoverSlots ([]);
( Array . isArray ( state . drag . sourceSlotIds ) ? state . drag . sourceSlotIds : [ state . drag . sourceSlotId ]). forEach (( slotId ) => {
getSlotElement ( slotId ) ? . classList . remove ( "is-drag-source" );
});
2026-04-01 12:31:56 -07:00
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 ) {
2026-04-13 14:28:03 -07:00
swapSlotAssignments ( sourceSlotId , targetSlotId );
2026-04-01 12:31:56 -07:00
}
function describeSlot ( slotId ) {
const [ rowText , columnText ] = String ( slotId || "" ). split ( ":" );
return `row ${ rowText || "?" } , column ${ columnText || "?" } ` ;
}
2026-04-07 03:16:37 -07:00
function openCardLightbox ( cardId , slotId = "" ) {
2026-04-01 12:31:56 -07:00
const card = getCardMap ( getCards ()). get ( String ( cardId || "" ). trim ()) || null ;
if ( ! card ) {
return ;
}
2026-04-07 03:16:37 -07:00
if ( isCustomFrameCard ( card )) {
editCustomCard ( slotId || findAssignedSlotIdByCardId ( cardId ), getCardId ( 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 ;
}
2026-04-13 14:28:03 -07:00
const pointerType = String ( event . pointerType || "" ). toLowerCase ();
2026-04-01 12:31:56 -07:00
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]" );
2026-04-13 14:28:03 -07:00
if ( emptyButton instanceof HTMLButtonElement && ( pointerType === "touch" || pointerType === "pen" )) {
2026-04-04 03:39:29 -07:00
event . preventDefault ();
scheduleLongPress ( String ( emptyButton . dataset . slotId || "" ), event );
}
2026-04-01 12:31:56 -07:00
return ;
}
2026-04-13 14:28:03 -07:00
const sourceSlotId = String ( cardButton . dataset . slotId || "" ). trim ();
const toggleSelectionShortcut = pointerType !== "touch" && ( event . ctrlKey || event . metaKey );
if ( toggleSelectionShortcut ) {
const selectionUpdate = setSelectedSlotIds (
isSlotSelected ( sourceSlotId )
? getSelectedSlotIds (). filter (( slotId ) => slotId !== sourceSlotId )
: [... getSelectedSlotIds (), sourceSlotId ]
);
if ( selectionUpdate . changed ) {
render ({ preserveViewport : true });
}
setStatus ( buildSelectionStatusMessage ( selectionUpdate . slotIds . length ));
state . suppressClick = true ;
event . preventDefault ();
return ;
}
if ( getSelectedSlotIds (). length && pointerType !== "touch" && ! isSlotSelected ( sourceSlotId )) {
setSelectedSlotIds ([]);
}
const dragSourceSlotIds = getDragSourceSlotIds ( sourceSlotId );
2026-04-01 12:31:56 -07:00
state . drag = {
pointerId : event . pointerId ,
2026-04-13 14:28:03 -07:00
pointerType ,
sourceSlotId ,
sourceSlotIds : dragSourceSlotIds . length ? dragSourceSlotIds : [ sourceSlotId ],
2026-04-01 12:31:56 -07:00
cardId : String ( cardButton . dataset . cardId || "" ),
startX : event . clientX ,
startY : event . clientY ,
2026-04-13 14:28:03 -07:00
touchEligibleAt : pointerType === "touch"
2026-04-04 12:15:52 -07:00
? ( 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-13 14:28:03 -07:00
hoverSlotIds : [],
dropPlan : null ,
invalidDropMessage : "" ,
2026-04-07 03:16:37 -07:00
deleteActive : false ,
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.
}
}
2026-04-13 14:28:03 -07:00
if ( pointerType === "touch" ) {
2026-04-02 22:06:19 -07:00
event . preventDefault ();
2026-04-13 14:28:03 -07:00
scheduleLongPress ( String ( cardButton . dataset . slotId || "" ), event , "card" );
2026-04-02 22:06:19 -07:00
}
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-13 14:28:03 -07:00
if ( state . drag . pointerType === "touch" && state . longPress ? . pointerId === event . 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 ;
2026-04-13 14:28:03 -07:00
state . drag . ghostEl = createDragGhost ( card , { groupSize : state . drag . sourceSlotIds . length });
state . drag . sourceSlotIds . forEach (( slotId ) => {
getSlotElement ( slotId ) ? . classList . add ( "is-drag-source" );
});
2026-04-07 03:16:37 -07:00
setDragTrashState ( true , false );
2026-04-01 12:31:56 -07:00
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 ;
2026-04-13 14:28:03 -07:00
const dragSlotIds = Array . isArray ( state . drag . sourceSlotIds ) && state . drag . sourceSlotIds . length
? state . drag . sourceSlotIds
: [ sourceSlotId ];
2026-04-01 12:31:56 -07:00
const targetSlotId = state . drag . hoverSlotId ;
2026-04-07 03:16:37 -07:00
const deleteActive = Boolean ( state . drag . deleteActive );
2026-04-13 14:28:03 -07:00
const dropPlan = state . drag . dropPlan ;
2026-04-01 12:31:56 -07:00
const draggedCard = getCardMap ( getCards ()). get ( state . drag . cardId ) || null ;
2026-04-13 14:28:03 -07:00
const singleWasSelected = dragSlotIds . length === 1 && isSlotSelected ( sourceSlotId );
const moved = dragSlotIds . length > 1
? Boolean ( dropPlan ? . valid && dropPlan . moved )
: Boolean ( targetSlotId && targetSlotId !== sourceSlotId );
2026-04-01 12:31:56 -07:00
2026-04-07 03:16:37 -07:00
if ( deleteActive ) {
2026-04-13 14:28:03 -07:00
dragSlotIds . forEach (( slotId ) => {
removeCardFromSlot ( slotId );
});
if ( dragSlotIds . length > 1 ) {
setSelectedSlotIds ([]);
}
2026-04-07 03:16:37 -07:00
render ({ preserveViewport : true });
2026-04-13 14:28:03 -07:00
setStatus ( dragSlotIds . length > 1
? `Removed ${ dragSlotIds . length } selected cards from the frame grid.`
: ` ${ getDisplayCardName ( draggedCard ) } removed from the frame grid.` );
} else if ( dragSlotIds . length > 1 && dropPlan ? . valid && dropPlan . moved ) {
applyGroupMove ( dropPlan );
setSelectedSlotIds ( dropPlan . targetSlotIds );
render ({ preserveViewport : true });
setStatus ( ` ${ dragSlotIds . length } selected cards moved together. Anchor card snapped to ${ describeSlot ( targetSlotId ) } .` );
2026-04-07 03:16:37 -07:00
} else if ( moved ) {
2026-04-01 12:31:56 -07:00
swapOrMoveSlots ( sourceSlotId , targetSlotId );
2026-04-13 14:28:03 -07:00
if ( singleWasSelected ) {
setSelectedSlotIds ([ 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 ) } .` );
2026-04-13 14:28:03 -07:00
} else if ( dragSlotIds . length > 1 && state . drag . invalidDropMessage ) {
setStatus ( state . drag . invalidDropMessage );
2026-04-01 12:31:56 -07:00
}
cleanupDrag ();
2026-04-07 03:16:37 -07:00
if ( ! moved && ! deleteActive ) {
2026-04-01 12:31:56 -07:00
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 ) {
2026-04-13 14:28:03 -07:00
const sourceSlotId = state . drag . sourceSlotId ;
const shouldToggleTouchSelection = state . drag . pointerType === "touch" && getSelectedSlotIds (). length > 0 ;
2026-04-01 12:31:56 -07:00
cleanupDrag ();
2026-04-13 14:28:03 -07:00
if ( shouldToggleTouchSelection ) {
const selectionUpdate = setSelectedSlotIds (
isSlotSelected ( sourceSlotId )
? getSelectedSlotIds (). filter (( slotId ) => slotId !== sourceSlotId )
: [... getSelectedSlotIds (), sourceSlotId ]
);
if ( selectionUpdate . changed ) {
render ({ preserveViewport : true });
}
setStatus ( buildSelectionStatusMessage ( selectionUpdate . slotIds . length ));
state . suppressClick = true ;
}
2026-04-01 12:31:56 -07:00
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 ;
}
2026-04-13 14:28:03 -07:00
if ( state . suppressClick ) {
state . suppressClick = false ;
2026-04-01 12:31:56 -07:00
return ;
}
2026-04-13 14:28:03 -07:00
const cardButton = target . closest ( ".tarot-frame-card[data-card-id]" );
if ( ! ( cardButton instanceof HTMLButtonElement )) {
const emptyButton = target . closest ( ".tarot-frame-card.is-empty[data-slot-id]" );
if ( emptyButton instanceof HTMLButtonElement && getSelectedSlotIds (). length > 0 ) {
const selectionUpdate = setSelectedSlotIds ([]);
if ( selectionUpdate . changed ) {
render ({ preserveViewport : true });
}
setStatus ( buildSelectionStatusMessage ( 0 ));
}
return ;
}
if ( isSlotSelected ( cardButton . dataset . slotId )) {
return ;
}
if ( getSelectedSlotIds (). length > 0 ) {
const selectionUpdate = setSelectedSlotIds ([]);
if ( selectionUpdate . changed ) {
render ({ preserveViewport : true });
}
setStatus ( buildSelectionStatusMessage ( 0 ));
2026-04-01 12:31:56 -07:00
return ;
}
2026-04-07 03:16:37 -07:00
openCardLightbox ( cardButton . dataset . cardId , cardButton . dataset . slotId );
2026-04-01 12:31:56 -07:00
}
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 ;
}
2026-04-13 14:28:03 -07:00
const occupiedButton = target . closest ( ".tarot-frame-card[data-card-id][data-slot-id]" );
if ( occupiedButton instanceof HTMLButtonElement ) {
event . preventDefault ();
event . stopPropagation ();
openCardActionMenu (
String ( occupiedButton . dataset . slotId || "" ),
String ( occupiedButton . dataset . cardId || "" ),
event . clientX ,
event . clientY ,
{ focusFirstButton : false }
);
return ;
2026-04-07 03:16:37 -07:00
}
2026-04-04 03:39:29 -07:00
const emptyButton = target . closest ( ".tarot-frame-card.is-empty[data-slot-id]" );
if ( ! ( emptyButton instanceof HTMLButtonElement )) {
return ;
}
event . preventDefault ();
event . stopPropagation ();
2026-04-07 03:16:37 -07:00
openCardInsertMenu ( String ( emptyButton . dataset . slotId || "" ), event . clientX , event . clientY );
2026-04-04 03:39:29 -07:00
}
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-07 03:16:37 -07:00
if ( state . cardInsertMenu . open && cardInsertMenuEl && ! cardInsertMenuEl . contains ( target )) {
closeCardInsertMenu ();
}
2026-04-13 14:28:03 -07:00
if ( state . cardActionMenu . open && cardActionMenuEl && ! cardActionMenuEl . contains ( target )) {
closeCardActionMenu ();
}
2026-04-07 03:16:37 -07:00
if ( cardCustomEditorEl && ! cardCustomEditorEl . hidden && ! cardCustomEditorEl . contains ( target )) {
closeCustomCardEditor ();
}
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-13 14:28:03 -07:00
const zoomDirection = getFrameGridZoomShortcutDirection ( event );
if ( zoomDirection && shouldHandleFrameZoomShortcut ( event )) {
event . preventDefault ();
setGridZoomStepIndex ( state . gridZoomStepIndex + zoomDirection );
return ;
}
2026-04-01 16:08:52 -07:00
if ( event . key !== "Escape" ) {
2026-04-01 12:31:56 -07:00
return ;
}
2026-04-13 14:28:03 -07:00
if ( getSelectedSlotIds (). length > 0 ) {
const selectionUpdate = setSelectedSlotIds ([]);
if ( selectionUpdate . changed ) {
render ({ preserveViewport : true });
}
setStatus ( buildSelectionStatusMessage ( 0 ));
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-07 03:16:37 -07:00
if ( state . cardInsertMenu . open ) {
closeCardInsertMenu ();
}
2026-04-13 14:28:03 -07:00
if ( state . cardActionMenu . open ) {
closeCardActionMenu ();
}
2026-04-07 03:16:37 -07:00
if ( cardCustomEditorEl && ! cardCustomEditorEl . hidden ) {
closeCustomCardEditor ();
}
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-07 03:16:37 -07:00
function drawImageCover ( 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 . max ( 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-07 03:16:37 -07:00
const primaryText = normalizeLabelText ( faceModel ? . primary );
2026-04-01 16:08:52 -07:00
const secondaryText = normalizeLabelText ( faceModel ? . secondary );
2026-04-01 19:26:38 -07:00
const maxWidth = width - 12 ;
2026-04-07 03:16:37 -07:00
const faceClasses = String ( faceModel ? . className || "" ). split ( /\s+/ ). filter ( Boolean );
const hasFaceClass = ( className ) => faceClasses . includes ( className );
2026-04-01 16:08:52 -07:00
context . save ();
2026-04-07 03:16:37 -07:00
if ( ! primaryText && ! secondaryText ) {
context . restore ();
return ;
}
const primaryFontSize = hasFaceClass ( "is-top-hebrew" ) && primaryText . length <= 3
? 14
: ( hasFaceClass ( "is-custom-hero" )
? 18
: ( hasFaceClass ( "is-custom-short" )
? 15
: ( hasFaceClass ( "is-custom-medium" ) ? 13 : 10 )));
const primaryFontFamily = hasFaceClass ( "is-top-hebrew" )
2026-04-01 16:08:52 -07:00
? "'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 ) : [];
2026-04-07 03:16:37 -07:00
const primaryLineHeight = hasFaceClass ( "is-top-hebrew" ) && primaryText . length <= 3
? 14
: ( hasFaceClass ( "is-custom-hero" )
? 18
: ( hasFaceClass ( "is-custom-short" )
? 15
: ( hasFaceClass ( "is-custom-medium" ) ? 13 : 11 )));
const secondaryFontSize = hasFaceClass ( "is-custom-hero" ) ? 8 : ( hasFaceClass ( "is-custom-short" ) ? 8 : 7 );
const secondaryLineHeight = hasFaceClass ( "is-custom-hero" ) ? 10 : 9 ;
2026-04-01 16:08:52 -07:00
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)" ;
2026-04-07 03:16:37 -07:00
context . font = `500 ${ secondaryFontSize } px 'Segoe UI', sans-serif` ;
2026-04-01 16:08:52 -07:00
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-13 14:28:03 -07:00
function drawSlotToCanvas ( context , x , y , width , height , card , image , customBackgroundImage = null , isFlipped = false ) {
2026-04-01 12:31:56 -07:00
if ( ! card ) {
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-07 03:16:37 -07:00
const faceModel = showImage ? null : buildCardTextFaceModel ( card );
2026-04-01 12:31:56 -07:00
context . save ();
2026-04-13 14:28:03 -07:00
if ( isFlipped ) {
context . translate ( cardX + ( cardWidth / 2 ), cardY + ( cardHeight / 2 ));
context . rotate ( Math . PI );
context . translate ( - ( cardX + ( cardWidth / 2 )), - ( cardY + ( cardHeight / 2 )));
}
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 {
2026-04-07 03:16:37 -07:00
const hasCustomLabelClass = String ( faceModel ? . className || "" ). split ( /\s+/ ). includes ( "is-custom-label" );
context . fillStyle = normalizeCustomCardBackgroundColor ( faceModel ? . backgroundColor ) || ( hasCustomLabelClass ? "#123548" : EXPORT_PANEL );
2026-04-01 19:26:38 -07:00
context . fillRect ( cardX , cardY , cardWidth , cardHeight );
2026-04-07 03:16:37 -07:00
if ( customBackgroundImage ) {
drawImageCover ( context , customBackgroundImage , cardX , cardY , cardWidth , cardHeight );
context . fillStyle = faceModel ? . primary || faceModel ? . secondary
? "rgba(15, 23, 42, 0.38)"
: "rgba(15, 23, 42, 0.16)" ;
context . fillRect ( cardX , cardY , cardWidth , cardHeight );
}
drawTextFaceToCanvas ( context , cardX , cardY , cardWidth , cardHeight , faceModel );
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 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 ;
});
}
}
2026-04-13 14:28:03 -07:00
context . restore ();
2026-04-01 12:31:56 -07:00
}
function loadCardImage ( src ) {
return new Promise (( resolve ) => {
2026-04-24 00:38:44 -07:00
const normalizedSrc = String ( src || "" ). trim ();
if ( ! normalizedSrc ) {
resolve ( null );
return ;
}
const ensureLoaded = window . TarotCardImages ? . ensureImageLoaded ;
const attachImageFallback = () => {
const image = new Image ();
image . decoding = "async" ;
image . onload = () => resolve ( image );
image . onerror = () => resolve ( null );
image . src = normalizedSrc ;
};
if ( typeof ensureLoaded === "function" ) {
Promise . resolve ( ensureLoaded ( normalizedSrc ))
. then (( cachedImage ) => {
if ( cachedImage ? . naturalWidth ) {
resolve ( cachedImage );
return ;
}
attachImageFallback ();
})
. catch (() => {
attachImageFallback ();
});
return ;
}
2026-04-01 12:31:56 -07:00
const image = new Image ();
image . decoding = "async" ;
image . onload = () => resolve ( image );
image . onerror = () => resolve ( null );
2026-04-24 00:38:44 -07:00
image . src = normalizedSrc ;
2026-04-01 12:31:56 -07:00
});
}
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 );
}));
2026-04-07 03:16:37 -07:00
const customBackgroundImageCache = new Map ();
state . customCards . forEach (( card ) => {
const src = normalizeCustomCardBackgroundImage ( card ? . backgroundImage );
if ( src && ! customBackgroundImageCache . has ( src )) {
customBackgroundImageCache . set ( src , loadCardImage ( src ));
}
});
const resolvedCustomBackgroundImages = new Map ();
await Promise . all ( Array . from ( state . customCards . values ()). map ( async ( card ) => {
const src = normalizeCustomCardBackgroundImage ( card ? . backgroundImage );
const image = src ? await customBackgroundImageCache . get ( src ) : null ;
resolvedCustomBackgroundImages . set ( getCardId ( card ), image || null );
}));
2026-04-01 12:31:56 -07:00
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 ));
2026-04-07 03:16:37 -07:00
drawSlotToCanvas (
context ,
x ,
y ,
EXPORT_SLOT_WIDTH ,
EXPORT_SLOT_HEIGHT ,
card ,
card ? resolvedImages . get ( getCardId ( card )) : null ,
2026-04-13 14:28:03 -07:00
card ? resolvedCustomBackgroundImages . get ( getCardId ( card )) : null ,
card ? isSlotFlipped ( slotId ) : false
2026-04-07 03:16:37 -07:00
);
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 ,
2026-04-13 14:28:03 -07:00
tarotFrameSelectionChipEl ,
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
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 );
2026-04-13 14:28:03 -07:00
tarotFrameBoardEl . addEventListener ( "wheel" , handleBoardWheel , { passive : false });
2026-04-04 03:39:29 -07:00
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 , "" );
});
}
2026-04-13 14:28:03 -07:00
if ( tarotFrameSelectionChipEl ) {
tarotFrameSelectionChipEl . addEventListener ( "click" , ( event ) => {
event . stopPropagation ();
if ( state . exportInProgress || ! getSelectedSlotIds (). length ) {
return ;
}
const selectionUpdate = setSelectedSlotIds ([]);
if ( selectionUpdate . changed ) {
render ({ preserveViewport : true });
}
setStatus ( buildSelectionStatusMessage ( 0 ));
});
}
2026-04-04 03:39:29 -07:00
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 ) {
2026-04-06 20:22:31 -07:00
tarotFrameLayoutPanelEl . addEventListener ( "change" , ( event ) => {
event . stopPropagation ();
const target = event . target ;
if ( ! ( target instanceof HTMLInputElement ) || target . dataset . layoutGuideVisible !== "true" ) {
return ;
}
state . layoutGuideVisible = Boolean ( target . checked );
applyLayoutGuideUi ();
});
2026-04-01 16:08:52 -07:00
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
};
})();