working companion prototype
@@ -16,21 +16,18 @@
|
||||
- Use Read/Search Group tools to gather current repo context, recent changes, and existing tests before delegating.
|
||||
- Identify task type (feature, enhancement, bugfix). Bugfixes mandate a Debug cycle; features may skip Debug unless failure signals appear.
|
||||
- Confirm docker E2E environment definitions exist; schedule creation or updates before implementation begins.
|
||||
- Engage Git mode before any other work: instruct it to capture the current tree status on the active branch (without forcing cleanliness), note any existing changes, and update the todo list with git-related tasks. If Git mode surfaces blocking issues, queue the follow-up delegations required to address them—never prompt the user for guidance.
|
||||
- Review existing BDD scenarios to understand product intent and outline the minimal behavior required for the current increment.
|
||||
|
||||
### Delegation Sequence
|
||||
|
||||
0. Acknowledge the prior mode’s `attempt_completion`, verify test status, update the todo list, and immediately determine the next mode to delegate—no idle time between handoffs.
|
||||
1. **Git – Status Check**: Delegate to Git mode to capture the current branch status and report readiness; no new branches may be created, and existing changes should be treated as intentional unless the user says otherwise.
|
||||
2. **Architect**: Request a concept-only plan covering Clean Architecture boundaries, BDD scenarios, dockerized environment impacts, and task breakdown; let Architect choose the best structure and documentation approach.
|
||||
3. **Ask** _(conditional)_: When gaps remain, direct Ask mode to mine existing artifacts (BDD suites, system docs, repository history) and surface explicit decisions without prescribing answers.
|
||||
4. **Debug** _(bugfix / failing tests only)_: Empower Debug mode to design and run the diagnostics necessary to pinpoint the defect and document the failing path.
|
||||
5. **Code – RED**: Authorize Code mode to craft the failing scenario/tests that reflect the planned behavior; require proof of failure (test output) before proceeding but trust Code mode to pick the most appropriate suites.
|
||||
6. **Code – GREEN**: After RED confirmation, allow Code mode to implement the minimal Clean Architecture-compliant solution, refactor safely, and drive every suite to green—let it decide how to structure components and abstractions within constraints.
|
||||
7. **Docs & Summary**: Instruct the responsible modes to capture any newly approved architecture notes, decisions, or test findings in the repository docs and update `ROADMAP.md` to reflect the latest big-picture todo status.
|
||||
8. **Git – Final Commit & Summary**: Once the entire todo list for the increment is cleared (code, tests, docs), command Git mode to stage the full set of scoped changes, produce the single final commit, and report branch plus hash details—never invoke Git mode just to commit isolated files or partial work.
|
||||
9. When additional scope remains, immediately repeat the loop with the next cohesive increment rather than batching work; never allow modes to accumulate multiple concerns in a single delegation or leave the workflow idle.
|
||||
1. **Architect**: Request a concept-only plan covering Clean Architecture boundaries, BDD scenarios, dockerized environment impacts, and task breakdown; let Architect choose the best structure and documentation approach.
|
||||
2. **Ask** _(conditional)_: When gaps remain, direct Ask mode to mine existing artifacts (BDD suites, system docs, repository history) and surface explicit decisions without prescribing answers.
|
||||
3. **Debug** _(bugfix / failing tests only)_: Empower Debug mode to design and run the diagnostics necessary to pinpoint the defect and document the failing path.
|
||||
4. **Code – RED**: Authorize Code mode to craft the failing scenario/tests that reflect the planned behavior; require proof of failure (test output) before proceeding but trust Code mode to pick the most appropriate suites.
|
||||
5. **Code – GREEN**: After RED confirmation, allow Code mode to implement the minimal Clean Architecture-compliant solution, refactor safely, and drive every suite to green—let it decide how to structure components and abstractions within constraints.
|
||||
6. **Docs & Summary**: Instruct the responsible modes to capture any newly approved architecture notes, decisions, or test findings in the repository docs and update `ROADMAP.md` to reflect the latest big-picture todo status.
|
||||
7. When additional scope remains, immediately repeat the loop with the next cohesive increment rather than batching work; never allow modes to accumulate multiple concerns in a single delegation or leave the workflow idle.
|
||||
|
||||
### Oversight & Quality Gates
|
||||
|
||||
@@ -40,8 +37,6 @@
|
||||
- Validate that docker-based E2E tests are executed as part of the GREEN verification; refuse completion without evidence.
|
||||
- Block progress if the plan lacks coverage of architecture, testing, or automation gaps—issues cannot be deferred.
|
||||
- Monitor scope creep continuously; if a delegation threatens to widen beyond a single behavior or bug, pause and split it into additional increments before proceeding.
|
||||
- Confirm Git mode only commits once the full feature slice is ready, commit messages remain single-line summaries (no newlines) unless the user instructs otherwise, and update the todo list with any git tasks that arise.
|
||||
- Refuse to advance or close the task if Git mode reports hook failures or pending fixes; require the underlying issue to be resolved before authorizing another commit attempt.
|
||||
- Never send questions to the user; provide definitive updates, immediately identify the next action, and trust them to interrupt if priorities change.
|
||||
- Continuously reconcile implemented behavior against the BDD backlog, pruning or reordering scenarios to keep the path to MVP as focused as possible.
|
||||
|
||||
@@ -50,4 +45,3 @@
|
||||
- All suites (unit, integration, dockerized E2E) have run and pass.
|
||||
- Code mode confirms final cleanup (no debug logs, no temporary scaffolding).
|
||||
- Documentation (including `ROADMAP.md`) reflects the final architecture, scenarios, fixes, and deployment state.
|
||||
- Provide the user with a concise status plus recommended next automated checks or follow-up tasks if any remain, include the branch name and commit hash from Git mode, reference Git mode’s merge guidance without restating it, and then close with an `attempt_completion` that marks the task green.
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
<html><head><style type="text/css">
|
||||
@keyframes gridpilot-pulse {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.85; transform: scale(1.03); }
|
||||
}
|
||||
|
||||
@keyframes gridpilot-spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@keyframes gridpilot-slide-in {
|
||||
from { transform: translateX(100%); opacity: 0; }
|
||||
to { transform: translateX(0); opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes gridpilot-checkered {
|
||||
0% { background-position: 0 0; }
|
||||
100% { background-position: 20px 20px; }
|
||||
}
|
||||
|
||||
@keyframes gridpilot-progress {
|
||||
0% { background-position: 0% 50%; }
|
||||
100% { background-position: 100% 50%; }
|
||||
}
|
||||
|
||||
#gridpilot-overlay {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
width: 340px;
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
z-index: 2147483647;
|
||||
animation: gridpilot-slide-in 0.4s ease-out;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
#gridpilot-overlay * {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.gridpilot-card {
|
||||
background: #12121B;
|
||||
border-radius: 4px;
|
||||
border: 1px solid rgba(183, 183, 187, 0.2);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.gridpilot-header {
|
||||
background: linear-gradient(90deg, #c8102e 0%, #a00d25 100%);
|
||||
padding: 10px 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.gridpilot-header::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background:
|
||||
linear-gradient(45deg, transparent 48%, rgba(255,255,255,0.05) 49%, rgba(255,255,255,0.05) 51%, transparent 52%),
|
||||
linear-gradient(-45deg, transparent 48%, rgba(255,255,255,0.05) 49%, rgba(255,255,255,0.05) 51%, transparent 52%);
|
||||
background-size: 8px 8px;
|
||||
animation: gridpilot-checkered 1.5s linear infinite;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.gridpilot-logo {
|
||||
font-size: 22px;
|
||||
animation: gridpilot-pulse 2s ease-in-out infinite;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.gridpilot-title {
|
||||
color: #ffffff;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1.5px;
|
||||
text-transform: uppercase;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
text-shadow: 0 1px 2px rgba(0,0,0,0.3);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.gridpilot-btn {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
color: #ffffff;
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 3px;
|
||||
padding: 4px 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.gridpilot-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
border-color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.gridpilot-btn:active {
|
||||
background: rgba(255, 255, 255, 0.35);
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
.gridpilot-btn.paused {
|
||||
background: #4e4e57;
|
||||
border-color: #ffffff;
|
||||
color: #ffffff;
|
||||
animation: gridpilot-pulse 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.gridpilot-close-btn {
|
||||
background: rgba(200, 16, 46, 0.6);
|
||||
border-color: rgba(200, 16, 46, 0.8);
|
||||
}
|
||||
|
||||
.gridpilot-close-btn:hover {
|
||||
background: rgba(200, 16, 46, 0.8);
|
||||
border-color: #c8102e;
|
||||
}
|
||||
|
||||
.gridpilot-close-btn:active {
|
||||
background: #c8102e;
|
||||
}
|
||||
|
||||
.gridpilot-header-buttons {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.gridpilot-body {
|
||||
padding: 14px;
|
||||
background: #1a1a24;
|
||||
}
|
||||
|
||||
.gridpilot-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.gridpilot-spinner {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border: 2px solid rgba(200, 16, 46, 0.3);
|
||||
border-top-color: #c8102e;
|
||||
border-radius: 50%;
|
||||
animation: gridpilot-spin 0.8s linear infinite;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.gridpilot-spinner.paused {
|
||||
animation-play-state: paused;
|
||||
border-top-color: #777880;
|
||||
border-color: rgba(119, 120, 128, 0.3);
|
||||
}
|
||||
|
||||
.gridpilot-action-text {
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.gridpilot-progress-container {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.gridpilot-progress-bar {
|
||||
height: 4px;
|
||||
background: rgba(78, 78, 87, 0.5);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.gridpilot-progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #c8102e, #e8304a, #c8102e);
|
||||
background-size: 200% 100%;
|
||||
animation: gridpilot-progress 2s linear infinite;
|
||||
border-radius: 2px;
|
||||
transition: width 0.4s ease-out;
|
||||
}
|
||||
|
||||
.gridpilot-progress-fill.paused {
|
||||
animation-play-state: paused;
|
||||
background: #777880;
|
||||
}
|
||||
|
||||
.gridpilot-step-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.gridpilot-step-text {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.gridpilot-step-count {
|
||||
color: #c8102e;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.gridpilot-personality {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
font-size: 11px;
|
||||
font-style: italic;
|
||||
text-align: center;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid rgba(183, 183, 187, 0.15);
|
||||
}
|
||||
|
||||
.gridpilot-footer {
|
||||
background: #12121B;
|
||||
padding: 8px 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
border-top: 1px solid rgba(183, 183, 187, 0.1);
|
||||
}
|
||||
|
||||
.gridpilot-footer-text {
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.gridpilot-footer-dot {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
background: #c8102e;
|
||||
border-radius: 50%;
|
||||
animation: gridpilot-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.gridpilot-footer-dot.paused {
|
||||
background: #777880;
|
||||
animation: none;
|
||||
}
|
||||
</style></head><body><div id="gridpilot-overlay">
|
||||
<div class="gridpilot-card">
|
||||
<div class="gridpilot-header">
|
||||
<span class="gridpilot-logo">🏎️</span>
|
||||
<span class="gridpilot-title">GridPilot</span>
|
||||
<div class="gridpilot-header-buttons">
|
||||
<button class="gridpilot-btn gridpilot-close-btn" id="gridpilot-close-btn" onclick="(function() {
|
||||
window.__gridpilot_close_requested = true;
|
||||
})()">Stop</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="gridpilot-body">
|
||||
<div class="gridpilot-status">
|
||||
<div class="gridpilot-spinner"></div>
|
||||
<span class="gridpilot-action-text" id="gridpilot-action">Processing step 0...</span>
|
||||
</div>
|
||||
<div class="gridpilot-progress-container">
|
||||
<div class="gridpilot-progress-bar">
|
||||
<div class="gridpilot-progress-fill" id="gridpilot-progress" style="width: 0%"></div>
|
||||
</div>
|
||||
<div class="gridpilot-step-info">
|
||||
<span class="gridpilot-step-text" id="gridpilot-step-text">Processing step 0...</span>
|
||||
<span class="gridpilot-step-count" id="gridpilot-step-count">Step 0 of 17</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="gridpilot-personality" id="gridpilot-personality">🏁 Getting ready for the green flag...</div>
|
||||
</div>
|
||||
<div class="gridpilot-footer">
|
||||
<div class="gridpilot-footer-dot"></div>
|
||||
<span class="gridpilot-footer-text">Automating your session setup</span>
|
||||
</div>
|
||||
</div>
|
||||
</div></body></html>
|
||||
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 448 KiB |
|
After Width: | Height: | Size: 389 KiB |
|
After Width: | Height: | Size: 390 KiB |
|
After Width: | Height: | Size: 389 KiB |
|
After Width: | Height: | Size: 388 KiB |
|
After Width: | Height: | Size: 320 KiB |
|
After Width: | Height: | Size: 112 KiB |
@@ -0,0 +1,173 @@
|
||||
<!DOCTYPE html><html lang="en" data-theme="light" style="color-scheme: light;"><head>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
||||
<meta http-equiv="Content-Security-Policy" content="script-src 'unsafe-eval' http://127.0.0.1:32034 'nonce-4zQa3IybSQdtfcoS2bblAh5C6Vn7COVP' 'strict-dynamic'">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<link rel="stylesheet" href="https://members-assets.iracing.com/public/shared-css/0e20cfa/styles/light.min.css">
|
||||
<link rel="icon" type="image/png" href="//images-static.iracing.com/favicon.png">
|
||||
<script async="" src="https://www.googletagmanager.com/gtm.js?id=GTM-TQBRJCCM"></script><script src="https://embed.twitch.tv/embed/v1.js" nonce=""></script>
|
||||
|
||||
<script nonce="">(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
|
||||
new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
|
||||
j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
|
||||
'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
|
||||
})(window,document,'script','dataLayer','GTM-TQBRJCCM');</script>
|
||||
|
||||
|
||||
<meta name="theme-color" content="#CDCDCF"><style type="text/css">.indiana-scroll-container {
|
||||
overflow: auto;
|
||||
}
|
||||
.indiana-scroll-container--dragging {
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
.indiana-scroll-container--dragging > * {
|
||||
pointer-events: none;
|
||||
cursor: -webkit-grab;
|
||||
cursor: grab;
|
||||
}
|
||||
.indiana-scroll-container--hide-scrollbars {
|
||||
overflow: hidden;
|
||||
overflow: -moz-scrollbars-none;
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.indiana-scroll-container--hide-scrollbars::-webkit-scrollbar {
|
||||
display: none !important;
|
||||
height: 0 !important;
|
||||
width: 0 !important;
|
||||
background: transparent !important;
|
||||
-webkit-appearance: none !important;
|
||||
}
|
||||
.indiana-scroll-container--native-scroll {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.indiana-dragging {
|
||||
cursor: -webkit-grab;
|
||||
cursor: grab;
|
||||
}</style><style data-emotion="css-global" data-s=""></style><style data-emotion="css-global" data-s=""></style><style data-emotion="css-global" data-s=""></style><style data-emotion="css-global" data-s=""></style><style data-emotion="css-global" data-s=""></style><style data-emotion="css-global" data-s=""></style><style data-emotion="css-global" data-s=""></style><style data-emotion="css" data-s=""></style></head>
|
||||
|
||||
<body id="IR_I" class="clear-bg chakra-ui-light">
|
||||
|
||||
<noscript><p>This website requires javascript and cookies to be enabled to use.</p></noscript>
|
||||
|
||||
<div id="app"><script id="chakra-script">!(function(){try{var a=function(c){var v="(prefers-color-scheme: dark)",h=window.matchMedia(v).matches?"dark":"light",r=c==="system"?h:c,o=document.documentElement,s=document.body,l="chakra-ui-light",d="chakra-ui-dark",i=r==="dark";return s.classList.add(i?d:l),s.classList.remove(i?l:d),o.style.colorScheme=r,o.dataset.theme=r,r},n=a,m="light",e="chakra-ui-color-mode",t=localStorage.getItem(e);t?a(t):localStorage.setItem(e,a(m))}catch(a){}})();</script><div class="css-3klkag"><canvas id="backgrounds" class="background-image" height="1080" width="1920" style="height: 100%; left: 0px; position: fixed; top: 0px; width: 100%; z-index: -1;"></canvas><div class="css-fzhj15"><div class="css-gmuwbf"><div class="chakra-stack css-dk69dq"><div class="css-3gbbd7"><span tabindex="0" class="css-1baulvz"><div class="css-155xtsn"><svg viewBox="0 0 305 56" width="305px" height="56px"><polygon fill="#184C91" points="102.1,19.2 89.6,19.2 80.6,39 93,39 "></polygon><polygon fill="#184C91" points="105.3,12.1 92.9,12.1 90.7,16.9 103.2,16.9 "></polygon><polygon fill="#184C91" points="226.5,19.2 214.1,19.2 205,39 217.4,39 "></polygon><polygon fill="#184C91" points="229.8,12.1 217.3,12.1 215.2,16.9 227.6,16.9 "></polygon><path fill="#184C91" d="M242.5,21.6h6.2l-8,17.4h12.4l6.2-13.4c1.6-3.5,0.1-6.3-3.3-6.3h-24.9L222.1,39h12.4L242.5,21.6z"></path><path fill="#184C91" d="M167.4,19.2h-21.8l-1.1,2.4h15.6L159,24h-12.4c-2,0-4.1,0.9-5.9,2.4c-1.4,1.1-2.5,2.5-3.2,4l-1.1,2.4
|
||||
c-1.6,3.5-0.1,6.3,3.3,6.3h24.9l6.2-13.4C172.3,22.1,170.8,19.2,167.4,19.2z M153.2,36.6H147l4.7-10.3h6.2L153.2,36.6z"></path><path fill="#184C91" d="M175.4,39h18.7c3.4,0,7.5-2.8,9.1-6.3l0.4-0.8h-12.4l-2.2,4.7h-6.2l6.9-15h6.2l-2.2,4.7h12.4l0.4-0.8
|
||||
c1.6-3.5,0.1-6.3-3.3-6.3h-18.7c-2,0-4.1,0.9-5.9,2.4c-1.4,1.1-2.5,2.5-3.2,4l-3.3,7.1C170.5,36.2,172,39,175.4,39z"></path><path fill="#184C91" d="M273.2,19.2L273.2,19.2C273.2,19.2,273.2,19.2,273.2,19.2c-2,0-4.1,0.9-5.9,2.4c-1.4,1.1-2.5,2.5-3.2,4
|
||||
l-3.3,7.1c-0.7,1.5-0.8,2.9-0.4,4c0.5,1.4,1.8,2.4,3.8,2.4h12.4l-1.1,2.4H79.3l-1.1,2.4h202.4c2,0,4.1-0.9,5.9-2.4
|
||||
c1.4-1.1,2.5-2.5,3.2-4l8.3-18.2H273.2z M277.6,36.6h-6.2l6.9-15h6.2L277.6,36.6z"></path><path fill="none" d="M70.2,13.5c0.2-0.3,0.5-0.6,0.7-0.9C70.7,12.9,70.4,13.2,70.2,13.5z"></path><path fill="none" d="M71.4,9.4c-0.1-0.1-0.2-0.2-0.2-0.3C71.2,9.2,71.3,9.3,71.4,9.4z"></path><path fill="#ffffff" d="M4.6,4.3c-0.7,0-1.1,0.4-1.6,1.1C2,6.5,2.3,6.4,2.6,7.2c0.3,0.9,0,1.6,0.8,2c0.4,0.2,0.8-0.3,0.9-0.9
|
||||
C4.6,9,4.8,9.4,5,10.2c0.3,0.4,0.8,1,1.2,0.8c0.7,0,0.9,0.5,1.1,1c-0.2,0.9,0,0.8,0.5,1.4c0.2,0.3,0.4,0.8,0.6,1
|
||||
c0.1,0.1,0.3,0.3,0.4,0.4c0.6,0.7,1,1.5,1.6,2.2c1.1,0.8,1.7,1.4,2.1,2.1c1.7,2.2,2.2,3.4,3.6,4.4c0.3,0.5,1,0.9,1.1,1.6
|
||||
c0.1,1.3,2.3,3.1,3.5,4.3c1.8,1.8,3.6,3.3,5.8,4.7c2,5.5,3.7,12.7,3.2,18.7c-0.3,1.2-0.5,2.2-0.8,3.3h19.8c-0.1-0.5-0.2-1-0.3-1.4
|
||||
c-0.1-0.3-0.2-0.7-0.2-1c0-0.2,0.2-0.4,0.2-0.7c0.4-6.2,2.2-10.3,3.4-15.6c0.2-0.9,0.4-1.7,0.3-2.1c1.4-0.9,2.7-1.5,3.7-3.2
|
||||
c2.1-1.5,3.4-3,3.6-4.5c0.7-0.7,2.3-0.6,2.3-2.1c0.7-0.7,1.1-1.3,1.3-2.1c1-1.3,2.4-2.7,2.5-3.6c1.4-1,2.3-1.7,1.8-2.8
|
||||
c0.8-0.4,1.2-1.2,1-1.9c0.6-0.7,1.7-0.8,2.1-1.5c0.2-0.3,0.5-0.6,0.7-0.9c0.2-0.2,0.4-0.4,0.5-0.6v-1.2c0,0,0-0.1,0-0.1V9.5
|
||||
c0,0,0-0.1-0.1-0.1c-0.1-0.1-0.2-0.2-0.2-0.3c0.1-0.9-0.4-1.3-1.4-1.4c0.1-0.8,0.4-1.8,0.7-2.7c0.4-0.7,0.5-1.2,0.2-1.5
|
||||
c-0.6-0.5-1,0-1.6,1.1c-0.3,0.6-0.7,1.5-1,2.1c-0.4,0.5-0.7,1-1,1.6c-0.6,0.7-1.3,1.8-1.3,2.7c-0.3,0.4-0.4,0.7-0.7,0.9
|
||||
c-0.1-0.1-0.4-0.1-0.6,0.2c-0.1,0.4-0.3,0.6-0.3,0.8c-0.7-0.3-1.2,0.3-1.6,1.5c-0.2,0.1-0.8,0.3-0.8,0.5c-0.3,0.2-0.4,0.5-0.7,0.7
|
||||
c-0.6,0.1-0.7,0.8-1.1,1.2c-0.4,0.1-0.7,0.6-0.9,1.1c-0.5,0-1,0.6-1.2,1.1c-0.9,0.5-1.2,0.9-1.5,1.7c-0.7,0.1-1.5,0.4-1.9,1.2
|
||||
c-0.7,0.4-1.5,1.2-1.8,1.9c-0.7,0.5-1.2,1.2-1.9,2.4c-0.8-0.4-1.7-0.4-2.5,0c-0.6-0.1-1.1-0.2-1.6-0.3c-0.3-0.3-0.3-0.6-0.1-0.9
|
||||
c0-0.3,0-0.7,0-1.1c0.2-0.8,0.5-1.7,0.7-2.5c0.6-1.6,0.6-3,0.4-4.4c0.1-1.3-0.1-2.5-0.6-3.9c-1.5-1.1-3.6-1.9-5.4-1.9
|
||||
c-2.2-0.3-3.8,0-5.3,0.8c-0.8,0.5-1.6,1.1-2.4,1.6c-0.9,0.4-1.7,2.5-2.5,3.8c-0.8,1.8-0.2,2,0.6,1.6h0c-1.5-0.1-0.1-1.4,0.5-1.4
|
||||
c2.1-0.2,4.4-0.2,7.7,1.9c1,0.6,2,1.1,2.9,0.8c0.4,0.5-0.4,1.2-1.4,1.5c-4.7,0.3-9.1,0.3-9.6-0.9c0.2-0.6,0.2-1.3,0.3-1.9
|
||||
c-0.1,0-0.2,0-0.2,0c0.3,0.2-0.1,1.5-0.2,2.3c-0.6,0.4-0.8,1.1-0.6,2c0.2,0.7,0.4,1.3,0.2,2.3c-0.3,0.1-0.6,0.1-0.8,0.1
|
||||
c-0.4-0.2-0.8-0.4-1.2-0.6c-0.6-0.2-1.1-0.1-1.5,0.1c-0.6-0.8-1.2-0.8-1.8-0.7c-0.4-0.3-0.8-0.7-1.2-1c-0.1-1.3-0.7-2.2-2-2.3
|
||||
c-0.5-0.4-1.1-0.6-1.6-1c0-0.3,0-0.7,0-1.1c-0.2-0.7-0.6-1.3-1.6-1.6c-0.8,0.1-1.1-0.7-1.6-1.5c-0.7-0.6-1.3-1.2-1.9-1.7
|
||||
c-0.3-0.5-1-0.7-1.3-1.2c-0.4-0.2-0.7-0.6-1.2-0.8c-0.2-0.2-0.4-0.8-0.8-1.1C13,10.9,12.7,11,12.5,11c-0.2-0.4-0.3-0.4-0.5-0.8
|
||||
c-0.5-0.4-0.9-0.7-1.4-1.1c-0.4-0.5-0.8-0.9-1.2-1.4C9.1,7.5,8.8,7.3,8.5,7C7.9,5.8,6.9,4.6,6.4,3.7C6,3.2,6.2,2.8,5.8,2.3
|
||||
c-0.2-0.3-0.3-0.7-0.5-1L4.7,0.1H4c-0.1,0.2-0.2,0.6-0.2,1C4,1.7,4.2,2.3,4.3,2.8C4.3,3.4,4.6,3.7,4.6,4.3z M31.8,25.6
|
||||
c0.7,3.1,6.4,1.8,12.3,0.2c-2.5,1.2-5,2.4-8.3,2.3C32.8,28,31.2,27.3,31.8,25.6z"></path><path fill="#184C91" d="M71.6,10.6c0,0.1-0.1,0.1-0.1,0.2c0,0.2,0.1,0.4,0.1,0.5V10.6z"></path><path fill="#184C91" d="M35.8,28.1c3.3,0.1,5.9-1.2,8.3-2.3c-5.9,1.6-11.6,2.9-12.3-0.2C31.2,27.3,32.8,28,35.8,28.1z"></path><path fill="#184C91" d="M71.4,9.4C71.4,9.4,71.4,9.4,71.4,9.4L71.4,9.4C71.4,9.4,71.4,9.4,71.4,9.4z"></path><path fill="#184C91" d="M71.5,12L71.5,12c-0.2,0.2-0.3,0.4-0.5,0.6C71.1,12.4,71.3,12.2,71.5,12z"></path><path fill="#184C91" d="M5.3,1.3c0.2,0.3,0.3,0.7,0.5,1L5.3,1.3z"></path><path fill="#184C91" d="M71.6,0h-67c0.1,0,0.1,0,0.1,0.1c0.2,0.4,0.4,0.8,0.6,1.2l0.5,1C6.2,2.8,6,3.2,6.4,3.7c0.5,1,1.5,2.2,2.1,3.4
|
||||
c0.3,0.2,0.6,0.5,0.9,0.7c0.4,0.5,0.8,0.9,1.2,1.4c0.5,0.3,0.9,0.7,1.4,1.1c0.2,0.4,0.3,0.4,0.5,0.8c0.2,0,0.5-0.1,0.8,0.2
|
||||
c0.3,0.3,0.6,0.9,0.8,1.1c0.5,0.2,0.7,0.6,1.2,0.8c0.3,0.5,1,0.7,1.3,1.2c0.7,0.5,1.3,1.2,1.9,1.7c0.5,0.8,0.7,1.6,1.6,1.5
|
||||
c1.1,0.3,1.4,1,1.6,1.6c0,0.3,0,0.7,0,1.1c0.6,0.4,1.1,0.6,1.6,1c1.2,0.1,1.9,1,2,2.3c0.4,0.3,0.8,0.7,1.2,1
|
||||
c0.6-0.1,1.2-0.1,1.8,0.7c0.4-0.2,0.9-0.3,1.5-0.1c0.4,0.2,0.8,0.4,1.2,0.6c0.3-0.1,0.6-0.1,0.8-0.1c0.2-1,0-1.7-0.2-2.3
|
||||
c-0.2-0.9,0-1.5,0.6-2c0.1-0.9,0.5-2.2,0.2-2.3c0.1,0,0.2,0,0.2,0c-0.1,0.6-0.1,1.3-0.3,1.9c0.4,1.2,4.9,1.1,9.6,0.9
|
||||
c1-0.2,1.8-1,1.4-1.5c-0.9,0.3-1.9-0.3-2.9-0.8c-3.3-2.1-5.6-2.1-7.7-1.9c-0.6,0.1-2,1.4-0.5,1.4h0c-0.8,0.4-1.4,0.2-0.6-1.6
|
||||
c0.8-1.3,1.6-3.3,2.5-3.8c0.8-0.5,1.6-1.1,2.4-1.6c1.5-0.8,3.1-1.1,5.3-0.8c1.8,0,3.9,0.9,5.4,1.9c0.6,1.3,0.7,2.5,0.6,3.9
|
||||
c0.2,1.4,0.2,2.9-0.4,4.4c-0.2,0.9-0.5,1.7-0.7,2.5c0,0.4,0,0.7,0,1.1c-0.2,0.3-0.2,0.6,0.1,0.9c0.6,0.1,1.1,0.2,1.6,0.3
|
||||
c0.9-0.4,1.8-0.4,2.5,0c0.7-1.2,1.2-1.9,1.9-2.4c0.3-0.8,1.1-1.5,1.8-1.9c0.4-0.8,1.2-1.1,1.9-1.2c0.3-0.7,0.6-1.1,1.5-1.7
|
||||
c0.2-0.5,0.7-1.1,1.2-1.1c0.2-0.4,0.5-0.9,0.9-1.1c0.4-0.4,0.5-1.1,1.1-1.2c0.3-0.2,0.4-0.5,0.7-0.7c0-0.2,0.6-0.4,0.8-0.5
|
||||
c0.4-1.2,0.9-1.8,1.6-1.5c0-0.3,0.2-0.5,0.3-0.8c0.2-0.3,0.5-0.2,0.6-0.2c0.3-0.2,0.4-0.4,0.7-0.9c0-0.9,0.7-2,1.3-2.7
|
||||
c0.3-0.7,0.6-1.1,1-1.6c0.3-0.6,0.7-1.5,1-2.1c0.6-1.1,1.1-1.7,1.6-1.1c0.3,0.3,0.2,0.8-0.2,1.5c-0.2,0.9-0.6,1.8-0.7,2.7
|
||||
c1,0.1,1.4,0.5,1.4,1.4c0.1,0.1,0.2,0.2,0.2,0.3c0,0,0,0.1,0.1,0.1v0c0,0,0.1,0.1,0.1,0.1L71.6,0z"></path><path fill="#184C91" d="M71.5,12L71.5,12c-0.2,0.2-0.4,0.4-0.5,0.6c-0.3,0.3-0.5,0.6-0.7,0.9c-0.4,0.7-1.5,0.8-2.1,1.5
|
||||
c0.2,0.8-0.2,1.6-1,1.9c0.5,1.1-0.4,1.8-1.8,2.8c-0.1,0.9-1.5,2.3-2.5,3.6c-0.1,0.8-0.5,1.4-1.3,2.1c0,1.5-1.6,1.3-2.3,2.1
|
||||
c-0.2,1.6-1.6,3-3.6,4.5c-0.9,1.7-2.3,2.3-3.7,3.2c0.1,0.4-0.2,1.2-0.3,2.1c-1.2,5.3-2.9,9.4-3.4,15.6c0,0.2-0.2,0.4-0.2,0.7
|
||||
c0,0.3,0.2,0.7,0.2,1c0.1,0.5,0.2,1,0.3,1.4h23l0-44.1C71.5,11.9,71.5,12,71.5,12z"></path><path fill="#D82727" d="M26.4,34c-2.2-1.4-4-2.8-5.8-4.7c-1.2-1.2-3.4-3-3.5-4.3c-0.1-0.7-0.7-1-1.1-1.6c-1.4-1.1-1.9-2.2-3.6-4.4
|
||||
c-0.4-0.7-1-1.3-2.1-2.1c-0.6-0.7-1-1.5-1.6-2.2c-0.1-0.1-0.3-0.3-0.4-0.4c-0.2-0.1-0.4-0.6-0.6-1c-0.5-0.6-0.7-0.5-0.5-1.4
|
||||
c-0.1-0.5-0.3-1-1.1-1c-0.5,0.2-0.9-0.4-1.2-0.8C4.8,9.4,4.6,9,4.3,8.2C4.2,8.8,3.8,9.3,3.4,9.1c-0.8-0.4-0.5-1-0.8-2
|
||||
C2.3,6.4,2,6.5,2.9,5.4c0.6-0.7,0.9-1.1,1.6-1.1c0-0.6-0.3-0.9-0.3-1.4C4.2,2.3,4,1.7,3.8,1.1c0-0.5,0-1,0.3-1.1H0v56h28.8
|
||||
c0.3-1.1,0.5-2.1,0.8-3.3C30.1,46.7,28.4,39.5,26.4,34z"></path><path fill="#184C91" d="M299.9,17.8c0-0.2,0-0.3,0.1-0.5c0-0.2,0.1-0.3,0.2-0.4c0.1-0.1,0.2-0.3,0.3-0.4c0.1-0.1,0.2-0.2,0.4-0.3
|
||||
c0.1-0.1,0.3-0.1,0.4-0.2c0.2,0,0.3-0.1,0.5-0.1c0.2,0,0.3,0,0.5,0.1c0.2,0,0.3,0.1,0.4,0.2c0.1,0.1,0.3,0.2,0.4,0.3
|
||||
c0.1,0.1,0.2,0.2,0.3,0.4c0.1,0.1,0.1,0.3,0.2,0.4c0,0.2,0.1,0.3,0.1,0.5c0,0.2,0,0.3-0.1,0.5c0,0.2-0.1,0.3-0.2,0.4
|
||||
c-0.1,0.1-0.2,0.3-0.3,0.4c-0.1,0.1-0.2,0.2-0.4,0.3c-0.1,0.1-0.3,0.1-0.4,0.2s-0.3,0.1-0.5,0.1c-0.2,0-0.3,0-0.5-0.1
|
||||
c-0.2,0-0.3-0.1-0.4-0.2c-0.1-0.1-0.3-0.2-0.4-0.3c-0.1-0.1-0.2-0.2-0.3-0.4c-0.1-0.1-0.1-0.3-0.2-0.4
|
||||
C299.9,18.1,299.9,18,299.9,17.8z M300.2,17.8c0,0.2,0,0.4,0.1,0.6c0.1,0.2,0.2,0.3,0.3,0.5c0.1,0.1,0.3,0.2,0.5,0.3
|
||||
c0.2,0.1,0.4,0.1,0.6,0.1c0.2,0,0.4,0,0.6-0.1c0.2-0.1,0.3-0.2,0.5-0.3c0.1-0.1,0.2-0.3,0.3-0.5c0.1-0.2,0.1-0.4,0.1-0.6
|
||||
c0-0.1,0-0.3-0.1-0.4c0-0.1-0.1-0.2-0.2-0.4c-0.1-0.1-0.1-0.2-0.2-0.3c-0.1-0.1-0.2-0.2-0.3-0.2c-0.1-0.1-0.2-0.1-0.4-0.1
|
||||
c-0.1,0-0.3,0-0.4,0c-0.1,0-0.3,0-0.4,0.1c-0.1,0-0.2,0.1-0.3,0.2c-0.1,0.1-0.2,0.1-0.3,0.2c-0.1,0.1-0.2,0.2-0.2,0.3
|
||||
c-0.1,0.1-0.1,0.2-0.1,0.4C300.2,17.5,300.2,17.6,300.2,17.8z M301.4,18.1l0,0.8l-0.5,0l0-2.2l0.8,0c0.3,0,0.5,0,0.7,0.1
|
||||
c0.1,0.1,0.2,0.3,0.2,0.5c0,0.1,0,0.3-0.1,0.4c-0.1,0.1-0.2,0.2-0.3,0.2c0,0,0.1,0,0.1,0.1c0,0,0,0.1,0.1,0.1l0.5,0.7l-0.5,0
|
||||
c-0.1,0-0.1,0-0.2-0.1l-0.4-0.6c0,0,0,0-0.1-0.1c0,0-0.1,0-0.1,0L301.4,18.1z M301.4,17.7l0.2,0c0.1,0,0.2,0,0.2,0
|
||||
c0.1,0,0.1,0,0.1-0.1c0,0,0-0.1,0.1-0.1c0,0,0-0.1,0-0.1c0-0.1,0-0.1,0-0.1c0,0,0-0.1-0.1-0.1c0,0-0.1,0-0.1-0.1c0,0-0.1,0-0.2,0
|
||||
l-0.3,0L301.4,17.7z"></path><path fill="#184C91" d="M110.1,39l3.3-7.1h0.8l2.2,7.1h12.4l-2.2-7.2c3.3-0.4,6.9-3,8.4-6.3l3.3-7.1c1.6-3.5,0.1-6.3-3.3-6.3H110
|
||||
L97.7,39H110.1z M121.4,14.5h6.2l-6.9,15h-6.2L121.4,14.5z"></path></svg></div></span></div><p class="chakra-text css-1mud8qf">You are not logged in.</p><div role="group" class="chakra-button__group chakra-stack css-4jt4m7" data-orientation="horizontal"><button type="button" class="chakra-button css-h9kfy" aria-label="Log in" tabindex="0"><span class="chakra-button__icon css-1wh2kri"><svg viewBox="0 0 16 16" focusable="false" class="chakra-icon css-onkibi" aria-hidden="true"><path d="M11.9999 1.5H9.74993C9.33571 1.5 8.99993 1.16421 8.99993 0.75C8.99993 0.335786 9.33571 0 9.74993 0H11.9999C13.1045 0 13.9999 0.895431 13.9999 2V14C13.9999 15.1046 13.1045 16 11.9999 16H9.74993C9.33571 16 8.99993 15.6642 8.99993 15.25C8.99993 14.8358 9.33571 14.5 9.74993 14.5H11.9999C12.2761 14.5 12.4999 14.2761 12.4999 14V2C12.4999 1.72386 12.2761 1.5 11.9999 1.5Z" fill="currentColor"></path><path d="M6.9267 4.43945C7.21959 4.14656 7.69447 4.14656 7.98736 4.43945L10.6338 7.0859C11.122 7.57406 11.122 8.36551 10.6338 8.85367L7.98736 11.5001C7.69447 11.793 7.21959 11.793 6.9267 11.5001C6.63381 11.2072 6.63381 10.7323 6.9267 10.4395L8.64637 8.71978L2.75 8.71978C2.33579 8.71978 2 8.384 2 7.96978C2 7.55557 2.33579 7.21978 2.75 7.21978L8.64637 7.21978L6.9267 5.50011C6.63381 5.20722 6.63381 4.73235 6.9267 4.43945Z" fill="currentColor"></path></svg></span>Log in</button></div></div></div></div></div><span id="__chakra_env" hidden=""></span></div>
|
||||
|
||||
<script nonce="">
|
||||
// Jumpstart theme
|
||||
(function load() {
|
||||
const cssCommitish = '0e20cfa'
|
||||
const environment = 'members'
|
||||
var chakraTheme = localStorage.getItem("chakra-ui-color-mode");
|
||||
var cookieTheme = document.cookie.replace(/(?:(?:^|.*;\s*)theme\s*=\s*([^;]*).*$)|^.*$/, "$1");
|
||||
var theme = chakraTheme || cookieTheme || "light";
|
||||
|
||||
// Browser bar style
|
||||
var meta = document.createElement("meta");
|
||||
meta.setAttribute("name", "theme-color");
|
||||
meta.setAttribute("content", theme === "dark" ? "#05050F" : "#CDCDCF");
|
||||
document.getElementsByTagName("head")[0].appendChild(meta);
|
||||
|
||||
if (theme === "dark") {
|
||||
document.documentElement.setAttribute("data-theme", "dark");
|
||||
document.querySelector(`link[href="https://${environment}-assets.iracing.com/public/shared-css/${cssCommitish}/styles/light.min.css"]`).href = `https://${environment}-assets.iracing.com/public/shared-css/${cssCommitish}/styles/dark.min.css`;
|
||||
localStorage.setItem("chakra-ui-color-mode", "dark");
|
||||
} else {
|
||||
document.documentElement.setAttribute("data-theme", "light");
|
||||
localStorage.setItem("chakra-ui-color-mode", "light");
|
||||
}
|
||||
})();
|
||||
|
||||
// Tag Manager
|
||||
(function() {
|
||||
const tagFrame = document.createElement("iframe");
|
||||
tagFrame.style.display = "none";
|
||||
tagFrame.style.visibility = "hidden";
|
||||
document.body.appendChild(tagFrame);
|
||||
tagFrame.setAttribute("src", "https://www.googletagmanager.com/ns.html?id=GTM-TQBRJCCM");
|
||||
})();
|
||||
|
||||
</script><iframe src="https://www.googletagmanager.com/ns.html?id=GTM-TQBRJCCM" style="display: none; visibility: hidden;"></iframe>
|
||||
|
||||
<script src="/web/js/features.06672f47edf44cc1.min.js" nonce=""></script>
|
||||
|
||||
<script src="/web/js/modals.ae8419431eceaffb.min.js" nonce=""></script>
|
||||
|
||||
<script src="/web/js/partials.5bbc922034ab9879.min.js" nonce=""></script>
|
||||
|
||||
<script src="/web/js/content.444652900591a9f4.min.js" nonce=""></script>
|
||||
|
||||
<script src="/web/js/pages.9bce6de2e3069f68.min.js" nonce=""></script>
|
||||
|
||||
<script src="/web/js/cards.9166c4c695a3fd3c.min.js" nonce=""></script>
|
||||
|
||||
<script src="/web/js/echarts.0fde742e8e70602d.min.js" nonce=""></script>
|
||||
|
||||
<script src="/web/js/moment.4ea910c71253b76a.min.js" nonce=""></script>
|
||||
|
||||
<script src="/web/js/interface.b0697a5bfaa16f8e.min.js" nonce=""></script>
|
||||
|
||||
<script src="/web/js/vendor.893c2f1bd68de2a3.min.js" nonce=""></script>
|
||||
|
||||
<script src="/web/js/main.bccb09d6090f2763.min.js" nonce=""></script><div class="chakra-portal"><div role="region" aria-live="polite" aria-label="Notifications-top" id="chakra-toast-manager-top" style="position: fixed; z-index: var(--toast-z-index, 5500); pointer-events: none; display: flex; flex-direction: column; margin: 0px auto; top: env(safe-area-inset-top, 0px); right: env(safe-area-inset-right, 0px); left: env(safe-area-inset-left, 0px);"></div><div role="region" aria-live="polite" aria-label="Notifications-top-left" id="chakra-toast-manager-top-left" style="position: fixed; z-index: var(--toast-z-index, 5500); pointer-events: none; display: flex; flex-direction: column; top: env(safe-area-inset-top, 0px); left: env(safe-area-inset-left, 0px);"></div><div role="region" aria-live="polite" aria-label="Notifications-top-right" id="chakra-toast-manager-top-right" style="position: fixed; z-index: var(--toast-z-index, 5500); pointer-events: none; display: flex; flex-direction: column; top: env(safe-area-inset-top, 0px); right: env(safe-area-inset-right, 0px);"></div><div role="region" aria-live="polite" aria-label="Notifications-bottom-left" id="chakra-toast-manager-bottom-left" style="position: fixed; z-index: var(--toast-z-index, 5500); pointer-events: none; display: flex; flex-direction: column; bottom: env(safe-area-inset-bottom, 0px); left: env(safe-area-inset-left, 0px);"></div><div role="region" aria-live="polite" aria-label="Notifications-bottom" id="chakra-toast-manager-bottom" style="position: fixed; z-index: var(--toast-z-index, 5500); pointer-events: none; display: flex; flex-direction: column; margin: 0px auto; bottom: env(safe-area-inset-bottom, 0px); right: env(safe-area-inset-right, 0px); left: env(safe-area-inset-left, 0px);"></div><div role="region" aria-live="polite" aria-label="Notifications-bottom-right" id="chakra-toast-manager-bottom-right" style="position: fixed; z-index: var(--toast-z-index, 5500); pointer-events: none; display: flex; flex-direction: column; bottom: env(safe-area-inset-bottom, 0px); right: env(safe-area-inset-right, 0px);"></div></div><div class="chakra-portal"><div role="region" aria-live="polite" aria-label="Notifications-top" id="chakra-toast-manager-top" style="position: fixed; z-index: var(--toast-z-index, 5500); pointer-events: none; display: flex; flex-direction: column; margin: 0px auto; top: env(safe-area-inset-top, 0px); right: env(safe-area-inset-right, 0px); left: env(safe-area-inset-left, 0px);"></div><div role="region" aria-live="polite" aria-label="Notifications-top-left" id="chakra-toast-manager-top-left" style="position: fixed; z-index: var(--toast-z-index, 5500); pointer-events: none; display: flex; flex-direction: column; top: env(safe-area-inset-top, 0px); left: env(safe-area-inset-left, 0px);"></div><div role="region" aria-live="polite" aria-label="Notifications-top-right" id="chakra-toast-manager-top-right" style="position: fixed; z-index: var(--toast-z-index, 5500); pointer-events: none; display: flex; flex-direction: column; top: env(safe-area-inset-top, 0px); right: env(safe-area-inset-right, 0px);"></div><div role="region" aria-live="polite" aria-label="Notifications-bottom-left" id="chakra-toast-manager-bottom-left" style="position: fixed; z-index: var(--toast-z-index, 5500); pointer-events: none; display: flex; flex-direction: column; bottom: env(safe-area-inset-bottom, 0px); left: env(safe-area-inset-left, 0px);"></div><div role="region" aria-live="polite" aria-label="Notifications-bottom" id="chakra-toast-manager-bottom" style="position: fixed; z-index: var(--toast-z-index, 5500); pointer-events: none; display: flex; flex-direction: column; margin: 0px auto; bottom: env(safe-area-inset-bottom, 0px); right: env(safe-area-inset-right, 0px); left: env(safe-area-inset-left, 0px);"></div><div role="region" aria-live="polite" aria-label="Notifications-bottom-right" id="chakra-toast-manager-bottom-right" style="position: fixed; z-index: var(--toast-z-index, 5500); pointer-events: none; display: flex; flex-direction: column; bottom: env(safe-area-inset-bottom, 0px); right: env(safe-area-inset-right, 0px);"></div></div>
|
||||
|
||||
|
||||
</body></html>
|
||||
|
After Width: | Height: | Size: 115 KiB |
@@ -0,0 +1,173 @@
|
||||
<!DOCTYPE html><html lang="en" data-theme="light" style="color-scheme: light;"><head>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
||||
<meta http-equiv="Content-Security-Policy" content="script-src 'unsafe-eval' http://127.0.0.1:32034 'nonce-ZJYFfpvHPnAo6foRS7su0emkiymfWgXF' 'strict-dynamic'">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<link rel="stylesheet" href="https://members-assets.iracing.com/public/shared-css/0e20cfa/styles/light.min.css">
|
||||
<link rel="icon" type="image/png" href="//images-static.iracing.com/favicon.png">
|
||||
<script async="" src="https://www.googletagmanager.com/gtm.js?id=GTM-TQBRJCCM"></script><script src="https://embed.twitch.tv/embed/v1.js" nonce=""></script>
|
||||
|
||||
<script nonce="">(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
|
||||
new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
|
||||
j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
|
||||
'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
|
||||
})(window,document,'script','dataLayer','GTM-TQBRJCCM');</script>
|
||||
|
||||
|
||||
<meta name="theme-color" content="#CDCDCF"><style type="text/css">.indiana-scroll-container {
|
||||
overflow: auto;
|
||||
}
|
||||
.indiana-scroll-container--dragging {
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
.indiana-scroll-container--dragging > * {
|
||||
pointer-events: none;
|
||||
cursor: -webkit-grab;
|
||||
cursor: grab;
|
||||
}
|
||||
.indiana-scroll-container--hide-scrollbars {
|
||||
overflow: hidden;
|
||||
overflow: -moz-scrollbars-none;
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.indiana-scroll-container--hide-scrollbars::-webkit-scrollbar {
|
||||
display: none !important;
|
||||
height: 0 !important;
|
||||
width: 0 !important;
|
||||
background: transparent !important;
|
||||
-webkit-appearance: none !important;
|
||||
}
|
||||
.indiana-scroll-container--native-scroll {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.indiana-dragging {
|
||||
cursor: -webkit-grab;
|
||||
cursor: grab;
|
||||
}</style><style data-emotion="css-global" data-s=""></style><style data-emotion="css-global" data-s=""></style><style data-emotion="css-global" data-s=""></style><style data-emotion="css-global" data-s=""></style><style data-emotion="css-global" data-s=""></style><style data-emotion="css-global" data-s=""></style><style data-emotion="css-global" data-s=""></style><style data-emotion="css" data-s=""></style></head>
|
||||
|
||||
<body id="IR_I" class="clear-bg chakra-ui-light">
|
||||
|
||||
<noscript><p>This website requires javascript and cookies to be enabled to use.</p></noscript>
|
||||
|
||||
<div id="app"><script id="chakra-script">!(function(){try{var a=function(c){var v="(prefers-color-scheme: dark)",h=window.matchMedia(v).matches?"dark":"light",r=c==="system"?h:c,o=document.documentElement,s=document.body,l="chakra-ui-light",d="chakra-ui-dark",i=r==="dark";return s.classList.add(i?d:l),s.classList.remove(i?l:d),o.style.colorScheme=r,o.dataset.theme=r,r},n=a,m="light",e="chakra-ui-color-mode",t=localStorage.getItem(e);t?a(t):localStorage.setItem(e,a(m))}catch(a){}})();</script><div class="css-3klkag"><canvas id="backgrounds" class="background-image" height="1080" width="1920" style="height: 100%; left: 0px; position: fixed; top: 0px; width: 100%; z-index: -1;"></canvas><div class="css-fzhj15"><div class="css-gmuwbf"><div class="chakra-stack css-dk69dq"><div class="css-3gbbd7"><span tabindex="0" class="css-1baulvz"><div class="css-155xtsn"><svg viewBox="0 0 305 56" width="305px" height="56px"><polygon fill="#184C91" points="102.1,19.2 89.6,19.2 80.6,39 93,39 "></polygon><polygon fill="#184C91" points="105.3,12.1 92.9,12.1 90.7,16.9 103.2,16.9 "></polygon><polygon fill="#184C91" points="226.5,19.2 214.1,19.2 205,39 217.4,39 "></polygon><polygon fill="#184C91" points="229.8,12.1 217.3,12.1 215.2,16.9 227.6,16.9 "></polygon><path fill="#184C91" d="M242.5,21.6h6.2l-8,17.4h12.4l6.2-13.4c1.6-3.5,0.1-6.3-3.3-6.3h-24.9L222.1,39h12.4L242.5,21.6z"></path><path fill="#184C91" d="M167.4,19.2h-21.8l-1.1,2.4h15.6L159,24h-12.4c-2,0-4.1,0.9-5.9,2.4c-1.4,1.1-2.5,2.5-3.2,4l-1.1,2.4
|
||||
c-1.6,3.5-0.1,6.3,3.3,6.3h24.9l6.2-13.4C172.3,22.1,170.8,19.2,167.4,19.2z M153.2,36.6H147l4.7-10.3h6.2L153.2,36.6z"></path><path fill="#184C91" d="M175.4,39h18.7c3.4,0,7.5-2.8,9.1-6.3l0.4-0.8h-12.4l-2.2,4.7h-6.2l6.9-15h6.2l-2.2,4.7h12.4l0.4-0.8
|
||||
c1.6-3.5,0.1-6.3-3.3-6.3h-18.7c-2,0-4.1,0.9-5.9,2.4c-1.4,1.1-2.5,2.5-3.2,4l-3.3,7.1C170.5,36.2,172,39,175.4,39z"></path><path fill="#184C91" d="M273.2,19.2L273.2,19.2C273.2,19.2,273.2,19.2,273.2,19.2c-2,0-4.1,0.9-5.9,2.4c-1.4,1.1-2.5,2.5-3.2,4
|
||||
l-3.3,7.1c-0.7,1.5-0.8,2.9-0.4,4c0.5,1.4,1.8,2.4,3.8,2.4h12.4l-1.1,2.4H79.3l-1.1,2.4h202.4c2,0,4.1-0.9,5.9-2.4
|
||||
c1.4-1.1,2.5-2.5,3.2-4l8.3-18.2H273.2z M277.6,36.6h-6.2l6.9-15h6.2L277.6,36.6z"></path><path fill="none" d="M70.2,13.5c0.2-0.3,0.5-0.6,0.7-0.9C70.7,12.9,70.4,13.2,70.2,13.5z"></path><path fill="none" d="M71.4,9.4c-0.1-0.1-0.2-0.2-0.2-0.3C71.2,9.2,71.3,9.3,71.4,9.4z"></path><path fill="#ffffff" d="M4.6,4.3c-0.7,0-1.1,0.4-1.6,1.1C2,6.5,2.3,6.4,2.6,7.2c0.3,0.9,0,1.6,0.8,2c0.4,0.2,0.8-0.3,0.9-0.9
|
||||
C4.6,9,4.8,9.4,5,10.2c0.3,0.4,0.8,1,1.2,0.8c0.7,0,0.9,0.5,1.1,1c-0.2,0.9,0,0.8,0.5,1.4c0.2,0.3,0.4,0.8,0.6,1
|
||||
c0.1,0.1,0.3,0.3,0.4,0.4c0.6,0.7,1,1.5,1.6,2.2c1.1,0.8,1.7,1.4,2.1,2.1c1.7,2.2,2.2,3.4,3.6,4.4c0.3,0.5,1,0.9,1.1,1.6
|
||||
c0.1,1.3,2.3,3.1,3.5,4.3c1.8,1.8,3.6,3.3,5.8,4.7c2,5.5,3.7,12.7,3.2,18.7c-0.3,1.2-0.5,2.2-0.8,3.3h19.8c-0.1-0.5-0.2-1-0.3-1.4
|
||||
c-0.1-0.3-0.2-0.7-0.2-1c0-0.2,0.2-0.4,0.2-0.7c0.4-6.2,2.2-10.3,3.4-15.6c0.2-0.9,0.4-1.7,0.3-2.1c1.4-0.9,2.7-1.5,3.7-3.2
|
||||
c2.1-1.5,3.4-3,3.6-4.5c0.7-0.7,2.3-0.6,2.3-2.1c0.7-0.7,1.1-1.3,1.3-2.1c1-1.3,2.4-2.7,2.5-3.6c1.4-1,2.3-1.7,1.8-2.8
|
||||
c0.8-0.4,1.2-1.2,1-1.9c0.6-0.7,1.7-0.8,2.1-1.5c0.2-0.3,0.5-0.6,0.7-0.9c0.2-0.2,0.4-0.4,0.5-0.6v-1.2c0,0,0-0.1,0-0.1V9.5
|
||||
c0,0,0-0.1-0.1-0.1c-0.1-0.1-0.2-0.2-0.2-0.3c0.1-0.9-0.4-1.3-1.4-1.4c0.1-0.8,0.4-1.8,0.7-2.7c0.4-0.7,0.5-1.2,0.2-1.5
|
||||
c-0.6-0.5-1,0-1.6,1.1c-0.3,0.6-0.7,1.5-1,2.1c-0.4,0.5-0.7,1-1,1.6c-0.6,0.7-1.3,1.8-1.3,2.7c-0.3,0.4-0.4,0.7-0.7,0.9
|
||||
c-0.1-0.1-0.4-0.1-0.6,0.2c-0.1,0.4-0.3,0.6-0.3,0.8c-0.7-0.3-1.2,0.3-1.6,1.5c-0.2,0.1-0.8,0.3-0.8,0.5c-0.3,0.2-0.4,0.5-0.7,0.7
|
||||
c-0.6,0.1-0.7,0.8-1.1,1.2c-0.4,0.1-0.7,0.6-0.9,1.1c-0.5,0-1,0.6-1.2,1.1c-0.9,0.5-1.2,0.9-1.5,1.7c-0.7,0.1-1.5,0.4-1.9,1.2
|
||||
c-0.7,0.4-1.5,1.2-1.8,1.9c-0.7,0.5-1.2,1.2-1.9,2.4c-0.8-0.4-1.7-0.4-2.5,0c-0.6-0.1-1.1-0.2-1.6-0.3c-0.3-0.3-0.3-0.6-0.1-0.9
|
||||
c0-0.3,0-0.7,0-1.1c0.2-0.8,0.5-1.7,0.7-2.5c0.6-1.6,0.6-3,0.4-4.4c0.1-1.3-0.1-2.5-0.6-3.9c-1.5-1.1-3.6-1.9-5.4-1.9
|
||||
c-2.2-0.3-3.8,0-5.3,0.8c-0.8,0.5-1.6,1.1-2.4,1.6c-0.9,0.4-1.7,2.5-2.5,3.8c-0.8,1.8-0.2,2,0.6,1.6h0c-1.5-0.1-0.1-1.4,0.5-1.4
|
||||
c2.1-0.2,4.4-0.2,7.7,1.9c1,0.6,2,1.1,2.9,0.8c0.4,0.5-0.4,1.2-1.4,1.5c-4.7,0.3-9.1,0.3-9.6-0.9c0.2-0.6,0.2-1.3,0.3-1.9
|
||||
c-0.1,0-0.2,0-0.2,0c0.3,0.2-0.1,1.5-0.2,2.3c-0.6,0.4-0.8,1.1-0.6,2c0.2,0.7,0.4,1.3,0.2,2.3c-0.3,0.1-0.6,0.1-0.8,0.1
|
||||
c-0.4-0.2-0.8-0.4-1.2-0.6c-0.6-0.2-1.1-0.1-1.5,0.1c-0.6-0.8-1.2-0.8-1.8-0.7c-0.4-0.3-0.8-0.7-1.2-1c-0.1-1.3-0.7-2.2-2-2.3
|
||||
c-0.5-0.4-1.1-0.6-1.6-1c0-0.3,0-0.7,0-1.1c-0.2-0.7-0.6-1.3-1.6-1.6c-0.8,0.1-1.1-0.7-1.6-1.5c-0.7-0.6-1.3-1.2-1.9-1.7
|
||||
c-0.3-0.5-1-0.7-1.3-1.2c-0.4-0.2-0.7-0.6-1.2-0.8c-0.2-0.2-0.4-0.8-0.8-1.1C13,10.9,12.7,11,12.5,11c-0.2-0.4-0.3-0.4-0.5-0.8
|
||||
c-0.5-0.4-0.9-0.7-1.4-1.1c-0.4-0.5-0.8-0.9-1.2-1.4C9.1,7.5,8.8,7.3,8.5,7C7.9,5.8,6.9,4.6,6.4,3.7C6,3.2,6.2,2.8,5.8,2.3
|
||||
c-0.2-0.3-0.3-0.7-0.5-1L4.7,0.1H4c-0.1,0.2-0.2,0.6-0.2,1C4,1.7,4.2,2.3,4.3,2.8C4.3,3.4,4.6,3.7,4.6,4.3z M31.8,25.6
|
||||
c0.7,3.1,6.4,1.8,12.3,0.2c-2.5,1.2-5,2.4-8.3,2.3C32.8,28,31.2,27.3,31.8,25.6z"></path><path fill="#184C91" d="M71.6,10.6c0,0.1-0.1,0.1-0.1,0.2c0,0.2,0.1,0.4,0.1,0.5V10.6z"></path><path fill="#184C91" d="M35.8,28.1c3.3,0.1,5.9-1.2,8.3-2.3c-5.9,1.6-11.6,2.9-12.3-0.2C31.2,27.3,32.8,28,35.8,28.1z"></path><path fill="#184C91" d="M71.4,9.4C71.4,9.4,71.4,9.4,71.4,9.4L71.4,9.4C71.4,9.4,71.4,9.4,71.4,9.4z"></path><path fill="#184C91" d="M71.5,12L71.5,12c-0.2,0.2-0.3,0.4-0.5,0.6C71.1,12.4,71.3,12.2,71.5,12z"></path><path fill="#184C91" d="M5.3,1.3c0.2,0.3,0.3,0.7,0.5,1L5.3,1.3z"></path><path fill="#184C91" d="M71.6,0h-67c0.1,0,0.1,0,0.1,0.1c0.2,0.4,0.4,0.8,0.6,1.2l0.5,1C6.2,2.8,6,3.2,6.4,3.7c0.5,1,1.5,2.2,2.1,3.4
|
||||
c0.3,0.2,0.6,0.5,0.9,0.7c0.4,0.5,0.8,0.9,1.2,1.4c0.5,0.3,0.9,0.7,1.4,1.1c0.2,0.4,0.3,0.4,0.5,0.8c0.2,0,0.5-0.1,0.8,0.2
|
||||
c0.3,0.3,0.6,0.9,0.8,1.1c0.5,0.2,0.7,0.6,1.2,0.8c0.3,0.5,1,0.7,1.3,1.2c0.7,0.5,1.3,1.2,1.9,1.7c0.5,0.8,0.7,1.6,1.6,1.5
|
||||
c1.1,0.3,1.4,1,1.6,1.6c0,0.3,0,0.7,0,1.1c0.6,0.4,1.1,0.6,1.6,1c1.2,0.1,1.9,1,2,2.3c0.4,0.3,0.8,0.7,1.2,1
|
||||
c0.6-0.1,1.2-0.1,1.8,0.7c0.4-0.2,0.9-0.3,1.5-0.1c0.4,0.2,0.8,0.4,1.2,0.6c0.3-0.1,0.6-0.1,0.8-0.1c0.2-1,0-1.7-0.2-2.3
|
||||
c-0.2-0.9,0-1.5,0.6-2c0.1-0.9,0.5-2.2,0.2-2.3c0.1,0,0.2,0,0.2,0c-0.1,0.6-0.1,1.3-0.3,1.9c0.4,1.2,4.9,1.1,9.6,0.9
|
||||
c1-0.2,1.8-1,1.4-1.5c-0.9,0.3-1.9-0.3-2.9-0.8c-3.3-2.1-5.6-2.1-7.7-1.9c-0.6,0.1-2,1.4-0.5,1.4h0c-0.8,0.4-1.4,0.2-0.6-1.6
|
||||
c0.8-1.3,1.6-3.3,2.5-3.8c0.8-0.5,1.6-1.1,2.4-1.6c1.5-0.8,3.1-1.1,5.3-0.8c1.8,0,3.9,0.9,5.4,1.9c0.6,1.3,0.7,2.5,0.6,3.9
|
||||
c0.2,1.4,0.2,2.9-0.4,4.4c-0.2,0.9-0.5,1.7-0.7,2.5c0,0.4,0,0.7,0,1.1c-0.2,0.3-0.2,0.6,0.1,0.9c0.6,0.1,1.1,0.2,1.6,0.3
|
||||
c0.9-0.4,1.8-0.4,2.5,0c0.7-1.2,1.2-1.9,1.9-2.4c0.3-0.8,1.1-1.5,1.8-1.9c0.4-0.8,1.2-1.1,1.9-1.2c0.3-0.7,0.6-1.1,1.5-1.7
|
||||
c0.2-0.5,0.7-1.1,1.2-1.1c0.2-0.4,0.5-0.9,0.9-1.1c0.4-0.4,0.5-1.1,1.1-1.2c0.3-0.2,0.4-0.5,0.7-0.7c0-0.2,0.6-0.4,0.8-0.5
|
||||
c0.4-1.2,0.9-1.8,1.6-1.5c0-0.3,0.2-0.5,0.3-0.8c0.2-0.3,0.5-0.2,0.6-0.2c0.3-0.2,0.4-0.4,0.7-0.9c0-0.9,0.7-2,1.3-2.7
|
||||
c0.3-0.7,0.6-1.1,1-1.6c0.3-0.6,0.7-1.5,1-2.1c0.6-1.1,1.1-1.7,1.6-1.1c0.3,0.3,0.2,0.8-0.2,1.5c-0.2,0.9-0.6,1.8-0.7,2.7
|
||||
c1,0.1,1.4,0.5,1.4,1.4c0.1,0.1,0.2,0.2,0.2,0.3c0,0,0,0.1,0.1,0.1v0c0,0,0.1,0.1,0.1,0.1L71.6,0z"></path><path fill="#184C91" d="M71.5,12L71.5,12c-0.2,0.2-0.4,0.4-0.5,0.6c-0.3,0.3-0.5,0.6-0.7,0.9c-0.4,0.7-1.5,0.8-2.1,1.5
|
||||
c0.2,0.8-0.2,1.6-1,1.9c0.5,1.1-0.4,1.8-1.8,2.8c-0.1,0.9-1.5,2.3-2.5,3.6c-0.1,0.8-0.5,1.4-1.3,2.1c0,1.5-1.6,1.3-2.3,2.1
|
||||
c-0.2,1.6-1.6,3-3.6,4.5c-0.9,1.7-2.3,2.3-3.7,3.2c0.1,0.4-0.2,1.2-0.3,2.1c-1.2,5.3-2.9,9.4-3.4,15.6c0,0.2-0.2,0.4-0.2,0.7
|
||||
c0,0.3,0.2,0.7,0.2,1c0.1,0.5,0.2,1,0.3,1.4h23l0-44.1C71.5,11.9,71.5,12,71.5,12z"></path><path fill="#D82727" d="M26.4,34c-2.2-1.4-4-2.8-5.8-4.7c-1.2-1.2-3.4-3-3.5-4.3c-0.1-0.7-0.7-1-1.1-1.6c-1.4-1.1-1.9-2.2-3.6-4.4
|
||||
c-0.4-0.7-1-1.3-2.1-2.1c-0.6-0.7-1-1.5-1.6-2.2c-0.1-0.1-0.3-0.3-0.4-0.4c-0.2-0.1-0.4-0.6-0.6-1c-0.5-0.6-0.7-0.5-0.5-1.4
|
||||
c-0.1-0.5-0.3-1-1.1-1c-0.5,0.2-0.9-0.4-1.2-0.8C4.8,9.4,4.6,9,4.3,8.2C4.2,8.8,3.8,9.3,3.4,9.1c-0.8-0.4-0.5-1-0.8-2
|
||||
C2.3,6.4,2,6.5,2.9,5.4c0.6-0.7,0.9-1.1,1.6-1.1c0-0.6-0.3-0.9-0.3-1.4C4.2,2.3,4,1.7,3.8,1.1c0-0.5,0-1,0.3-1.1H0v56h28.8
|
||||
c0.3-1.1,0.5-2.1,0.8-3.3C30.1,46.7,28.4,39.5,26.4,34z"></path><path fill="#184C91" d="M299.9,17.8c0-0.2,0-0.3,0.1-0.5c0-0.2,0.1-0.3,0.2-0.4c0.1-0.1,0.2-0.3,0.3-0.4c0.1-0.1,0.2-0.2,0.4-0.3
|
||||
c0.1-0.1,0.3-0.1,0.4-0.2c0.2,0,0.3-0.1,0.5-0.1c0.2,0,0.3,0,0.5,0.1c0.2,0,0.3,0.1,0.4,0.2c0.1,0.1,0.3,0.2,0.4,0.3
|
||||
c0.1,0.1,0.2,0.2,0.3,0.4c0.1,0.1,0.1,0.3,0.2,0.4c0,0.2,0.1,0.3,0.1,0.5c0,0.2,0,0.3-0.1,0.5c0,0.2-0.1,0.3-0.2,0.4
|
||||
c-0.1,0.1-0.2,0.3-0.3,0.4c-0.1,0.1-0.2,0.2-0.4,0.3c-0.1,0.1-0.3,0.1-0.4,0.2s-0.3,0.1-0.5,0.1c-0.2,0-0.3,0-0.5-0.1
|
||||
c-0.2,0-0.3-0.1-0.4-0.2c-0.1-0.1-0.3-0.2-0.4-0.3c-0.1-0.1-0.2-0.2-0.3-0.4c-0.1-0.1-0.1-0.3-0.2-0.4
|
||||
C299.9,18.1,299.9,18,299.9,17.8z M300.2,17.8c0,0.2,0,0.4,0.1,0.6c0.1,0.2,0.2,0.3,0.3,0.5c0.1,0.1,0.3,0.2,0.5,0.3
|
||||
c0.2,0.1,0.4,0.1,0.6,0.1c0.2,0,0.4,0,0.6-0.1c0.2-0.1,0.3-0.2,0.5-0.3c0.1-0.1,0.2-0.3,0.3-0.5c0.1-0.2,0.1-0.4,0.1-0.6
|
||||
c0-0.1,0-0.3-0.1-0.4c0-0.1-0.1-0.2-0.2-0.4c-0.1-0.1-0.1-0.2-0.2-0.3c-0.1-0.1-0.2-0.2-0.3-0.2c-0.1-0.1-0.2-0.1-0.4-0.1
|
||||
c-0.1,0-0.3,0-0.4,0c-0.1,0-0.3,0-0.4,0.1c-0.1,0-0.2,0.1-0.3,0.2c-0.1,0.1-0.2,0.1-0.3,0.2c-0.1,0.1-0.2,0.2-0.2,0.3
|
||||
c-0.1,0.1-0.1,0.2-0.1,0.4C300.2,17.5,300.2,17.6,300.2,17.8z M301.4,18.1l0,0.8l-0.5,0l0-2.2l0.8,0c0.3,0,0.5,0,0.7,0.1
|
||||
c0.1,0.1,0.2,0.3,0.2,0.5c0,0.1,0,0.3-0.1,0.4c-0.1,0.1-0.2,0.2-0.3,0.2c0,0,0.1,0,0.1,0.1c0,0,0,0.1,0.1,0.1l0.5,0.7l-0.5,0
|
||||
c-0.1,0-0.1,0-0.2-0.1l-0.4-0.6c0,0,0,0-0.1-0.1c0,0-0.1,0-0.1,0L301.4,18.1z M301.4,17.7l0.2,0c0.1,0,0.2,0,0.2,0
|
||||
c0.1,0,0.1,0,0.1-0.1c0,0,0-0.1,0.1-0.1c0,0,0-0.1,0-0.1c0-0.1,0-0.1,0-0.1c0,0,0-0.1-0.1-0.1c0,0-0.1,0-0.1-0.1c0,0-0.1,0-0.2,0
|
||||
l-0.3,0L301.4,17.7z"></path><path fill="#184C91" d="M110.1,39l3.3-7.1h0.8l2.2,7.1h12.4l-2.2-7.2c3.3-0.4,6.9-3,8.4-6.3l3.3-7.1c1.6-3.5,0.1-6.3-3.3-6.3H110
|
||||
L97.7,39H110.1z M121.4,14.5h6.2l-6.9,15h-6.2L121.4,14.5z"></path></svg></div></span></div><p class="chakra-text css-1mud8qf">You are not logged in.</p><div role="group" class="chakra-button__group chakra-stack css-4jt4m7" data-orientation="horizontal"><button type="button" class="chakra-button css-h9kfy" aria-label="Log in" tabindex="0"><span class="chakra-button__icon css-1wh2kri"><svg viewBox="0 0 16 16" focusable="false" class="chakra-icon css-onkibi" aria-hidden="true"><path d="M11.9999 1.5H9.74993C9.33571 1.5 8.99993 1.16421 8.99993 0.75C8.99993 0.335786 9.33571 0 9.74993 0H11.9999C13.1045 0 13.9999 0.895431 13.9999 2V14C13.9999 15.1046 13.1045 16 11.9999 16H9.74993C9.33571 16 8.99993 15.6642 8.99993 15.25C8.99993 14.8358 9.33571 14.5 9.74993 14.5H11.9999C12.2761 14.5 12.4999 14.2761 12.4999 14V2C12.4999 1.72386 12.2761 1.5 11.9999 1.5Z" fill="currentColor"></path><path d="M6.9267 4.43945C7.21959 4.14656 7.69447 4.14656 7.98736 4.43945L10.6338 7.0859C11.122 7.57406 11.122 8.36551 10.6338 8.85367L7.98736 11.5001C7.69447 11.793 7.21959 11.793 6.9267 11.5001C6.63381 11.2072 6.63381 10.7323 6.9267 10.4395L8.64637 8.71978L2.75 8.71978C2.33579 8.71978 2 8.384 2 7.96978C2 7.55557 2.33579 7.21978 2.75 7.21978L8.64637 7.21978L6.9267 5.50011C6.63381 5.20722 6.63381 4.73235 6.9267 4.43945Z" fill="currentColor"></path></svg></span>Log in</button></div></div></div></div></div><span id="__chakra_env" hidden=""></span></div>
|
||||
|
||||
<script nonce="">
|
||||
// Jumpstart theme
|
||||
(function load() {
|
||||
const cssCommitish = '0e20cfa'
|
||||
const environment = 'members'
|
||||
var chakraTheme = localStorage.getItem("chakra-ui-color-mode");
|
||||
var cookieTheme = document.cookie.replace(/(?:(?:^|.*;\s*)theme\s*=\s*([^;]*).*$)|^.*$/, "$1");
|
||||
var theme = chakraTheme || cookieTheme || "light";
|
||||
|
||||
// Browser bar style
|
||||
var meta = document.createElement("meta");
|
||||
meta.setAttribute("name", "theme-color");
|
||||
meta.setAttribute("content", theme === "dark" ? "#05050F" : "#CDCDCF");
|
||||
document.getElementsByTagName("head")[0].appendChild(meta);
|
||||
|
||||
if (theme === "dark") {
|
||||
document.documentElement.setAttribute("data-theme", "dark");
|
||||
document.querySelector(`link[href="https://${environment}-assets.iracing.com/public/shared-css/${cssCommitish}/styles/light.min.css"]`).href = `https://${environment}-assets.iracing.com/public/shared-css/${cssCommitish}/styles/dark.min.css`;
|
||||
localStorage.setItem("chakra-ui-color-mode", "dark");
|
||||
} else {
|
||||
document.documentElement.setAttribute("data-theme", "light");
|
||||
localStorage.setItem("chakra-ui-color-mode", "light");
|
||||
}
|
||||
})();
|
||||
|
||||
// Tag Manager
|
||||
(function() {
|
||||
const tagFrame = document.createElement("iframe");
|
||||
tagFrame.style.display = "none";
|
||||
tagFrame.style.visibility = "hidden";
|
||||
document.body.appendChild(tagFrame);
|
||||
tagFrame.setAttribute("src", "https://www.googletagmanager.com/ns.html?id=GTM-TQBRJCCM");
|
||||
})();
|
||||
|
||||
</script><iframe src="https://www.googletagmanager.com/ns.html?id=GTM-TQBRJCCM" style="display: none; visibility: hidden;"></iframe>
|
||||
|
||||
<script src="/web/js/features.06672f47edf44cc1.min.js" nonce=""></script>
|
||||
|
||||
<script src="/web/js/modals.ae8419431eceaffb.min.js" nonce=""></script>
|
||||
|
||||
<script src="/web/js/partials.5bbc922034ab9879.min.js" nonce=""></script>
|
||||
|
||||
<script src="/web/js/content.444652900591a9f4.min.js" nonce=""></script>
|
||||
|
||||
<script src="/web/js/pages.9bce6de2e3069f68.min.js" nonce=""></script>
|
||||
|
||||
<script src="/web/js/cards.9166c4c695a3fd3c.min.js" nonce=""></script>
|
||||
|
||||
<script src="/web/js/echarts.0fde742e8e70602d.min.js" nonce=""></script>
|
||||
|
||||
<script src="/web/js/moment.4ea910c71253b76a.min.js" nonce=""></script>
|
||||
|
||||
<script src="/web/js/interface.b0697a5bfaa16f8e.min.js" nonce=""></script>
|
||||
|
||||
<script src="/web/js/vendor.893c2f1bd68de2a3.min.js" nonce=""></script>
|
||||
|
||||
<script src="/web/js/main.bccb09d6090f2763.min.js" nonce=""></script><div class="chakra-portal"><div role="region" aria-live="polite" aria-label="Notifications-top" id="chakra-toast-manager-top" style="position: fixed; z-index: var(--toast-z-index, 5500); pointer-events: none; display: flex; flex-direction: column; margin: 0px auto; top: env(safe-area-inset-top, 0px); right: env(safe-area-inset-right, 0px); left: env(safe-area-inset-left, 0px);"></div><div role="region" aria-live="polite" aria-label="Notifications-top-left" id="chakra-toast-manager-top-left" style="position: fixed; z-index: var(--toast-z-index, 5500); pointer-events: none; display: flex; flex-direction: column; top: env(safe-area-inset-top, 0px); left: env(safe-area-inset-left, 0px);"></div><div role="region" aria-live="polite" aria-label="Notifications-top-right" id="chakra-toast-manager-top-right" style="position: fixed; z-index: var(--toast-z-index, 5500); pointer-events: none; display: flex; flex-direction: column; top: env(safe-area-inset-top, 0px); right: env(safe-area-inset-right, 0px);"></div><div role="region" aria-live="polite" aria-label="Notifications-bottom-left" id="chakra-toast-manager-bottom-left" style="position: fixed; z-index: var(--toast-z-index, 5500); pointer-events: none; display: flex; flex-direction: column; bottom: env(safe-area-inset-bottom, 0px); left: env(safe-area-inset-left, 0px);"></div><div role="region" aria-live="polite" aria-label="Notifications-bottom" id="chakra-toast-manager-bottom" style="position: fixed; z-index: var(--toast-z-index, 5500); pointer-events: none; display: flex; flex-direction: column; margin: 0px auto; bottom: env(safe-area-inset-bottom, 0px); right: env(safe-area-inset-right, 0px); left: env(safe-area-inset-left, 0px);"></div><div role="region" aria-live="polite" aria-label="Notifications-bottom-right" id="chakra-toast-manager-bottom-right" style="position: fixed; z-index: var(--toast-z-index, 5500); pointer-events: none; display: flex; flex-direction: column; bottom: env(safe-area-inset-bottom, 0px); right: env(safe-area-inset-right, 0px);"></div></div><div class="chakra-portal"><div role="region" aria-live="polite" aria-label="Notifications-top" id="chakra-toast-manager-top" style="position: fixed; z-index: var(--toast-z-index, 5500); pointer-events: none; display: flex; flex-direction: column; margin: 0px auto; top: env(safe-area-inset-top, 0px); right: env(safe-area-inset-right, 0px); left: env(safe-area-inset-left, 0px);"></div><div role="region" aria-live="polite" aria-label="Notifications-top-left" id="chakra-toast-manager-top-left" style="position: fixed; z-index: var(--toast-z-index, 5500); pointer-events: none; display: flex; flex-direction: column; top: env(safe-area-inset-top, 0px); left: env(safe-area-inset-left, 0px);"></div><div role="region" aria-live="polite" aria-label="Notifications-top-right" id="chakra-toast-manager-top-right" style="position: fixed; z-index: var(--toast-z-index, 5500); pointer-events: none; display: flex; flex-direction: column; top: env(safe-area-inset-top, 0px); right: env(safe-area-inset-right, 0px);"></div><div role="region" aria-live="polite" aria-label="Notifications-bottom-left" id="chakra-toast-manager-bottom-left" style="position: fixed; z-index: var(--toast-z-index, 5500); pointer-events: none; display: flex; flex-direction: column; bottom: env(safe-area-inset-bottom, 0px); left: env(safe-area-inset-left, 0px);"></div><div role="region" aria-live="polite" aria-label="Notifications-bottom" id="chakra-toast-manager-bottom" style="position: fixed; z-index: var(--toast-z-index, 5500); pointer-events: none; display: flex; flex-direction: column; margin: 0px auto; bottom: env(safe-area-inset-bottom, 0px); right: env(safe-area-inset-right, 0px); left: env(safe-area-inset-left, 0px);"></div><div role="region" aria-live="polite" aria-label="Notifications-bottom-right" id="chakra-toast-manager-bottom-right" style="position: fixed; z-index: var(--toast-z-index, 5500); pointer-events: none; display: flex; flex-direction: column; bottom: env(safe-area-inset-bottom, 0px); right: env(safe-area-inset-right, 0px);"></div></div>
|
||||
|
||||
|
||||
</body></html>
|
||||
|
After Width: | Height: | Size: 74 KiB |
@@ -0,0 +1,467 @@
|
||||
<!DOCTYPE html><html lang="en" data-theme="light" style="color-scheme: light;"><head>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
||||
<meta http-equiv="Content-Security-Policy" content="script-src 'unsafe-eval' http://127.0.0.1:32034 'nonce-VJ6TAItV76hCmXruveYHk6ByiGGkflR2' 'strict-dynamic'">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<link rel="stylesheet" href="https://members-assets.iracing.com/public/shared-css/0e20cfa/styles/light.min.css">
|
||||
<link rel="icon" type="image/png" href="//images-static.iracing.com/favicon.png">
|
||||
<script async="" src="https://www.googletagmanager.com/gtm.js?id=GTM-TQBRJCCM"></script><script src="https://embed.twitch.tv/embed/v1.js" nonce=""></script>
|
||||
|
||||
<script nonce="">(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
|
||||
new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
|
||||
j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
|
||||
'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
|
||||
})(window,document,'script','dataLayer','GTM-TQBRJCCM');</script>
|
||||
|
||||
|
||||
<meta name="theme-color" content="#CDCDCF"><style type="text/css">.indiana-scroll-container {
|
||||
overflow: auto;
|
||||
}
|
||||
.indiana-scroll-container--dragging {
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
.indiana-scroll-container--dragging > * {
|
||||
pointer-events: none;
|
||||
cursor: -webkit-grab;
|
||||
cursor: grab;
|
||||
}
|
||||
.indiana-scroll-container--hide-scrollbars {
|
||||
overflow: hidden;
|
||||
overflow: -moz-scrollbars-none;
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.indiana-scroll-container--hide-scrollbars::-webkit-scrollbar {
|
||||
display: none !important;
|
||||
height: 0 !important;
|
||||
width: 0 !important;
|
||||
background: transparent !important;
|
||||
-webkit-appearance: none !important;
|
||||
}
|
||||
.indiana-scroll-container--native-scroll {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.indiana-dragging {
|
||||
cursor: -webkit-grab;
|
||||
cursor: grab;
|
||||
}</style><style data-emotion="css-global" data-s=""></style><style data-emotion="css-global" data-s=""></style><style data-emotion="css-global" data-s=""></style><style data-emotion="css-global" data-s=""></style><style data-emotion="css-global" data-s=""></style><style data-emotion="css-global" data-s=""></style><style data-emotion="css-global" data-s=""></style><style data-emotion="css" data-s=""></style><style type="text/css">
|
||||
@keyframes gridpilot-pulse {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.85; transform: scale(1.03); }
|
||||
}
|
||||
|
||||
@keyframes gridpilot-spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@keyframes gridpilot-slide-in {
|
||||
from { transform: translateX(100%); opacity: 0; }
|
||||
to { transform: translateX(0); opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes gridpilot-checkered {
|
||||
0% { background-position: 0 0; }
|
||||
100% { background-position: 20px 20px; }
|
||||
}
|
||||
|
||||
@keyframes gridpilot-progress {
|
||||
0% { background-position: 0% 50%; }
|
||||
100% { background-position: 100% 50%; }
|
||||
}
|
||||
|
||||
#gridpilot-overlay {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
width: 340px;
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
z-index: 2147483647;
|
||||
animation: gridpilot-slide-in 0.4s ease-out;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
#gridpilot-overlay * {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.gridpilot-card {
|
||||
background: #12121B;
|
||||
border-radius: 4px;
|
||||
border: 1px solid rgba(183, 183, 187, 0.2);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.gridpilot-header {
|
||||
background: linear-gradient(90deg, #c8102e 0%, #a00d25 100%);
|
||||
padding: 10px 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.gridpilot-header::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background:
|
||||
linear-gradient(45deg, transparent 48%, rgba(255,255,255,0.05) 49%, rgba(255,255,255,0.05) 51%, transparent 52%),
|
||||
linear-gradient(-45deg, transparent 48%, rgba(255,255,255,0.05) 49%, rgba(255,255,255,0.05) 51%, transparent 52%);
|
||||
background-size: 8px 8px;
|
||||
animation: gridpilot-checkered 1.5s linear infinite;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.gridpilot-logo {
|
||||
font-size: 22px;
|
||||
animation: gridpilot-pulse 2s ease-in-out infinite;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.gridpilot-title {
|
||||
color: #ffffff;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1.5px;
|
||||
text-transform: uppercase;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
text-shadow: 0 1px 2px rgba(0,0,0,0.3);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.gridpilot-btn {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
color: #ffffff;
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 3px;
|
||||
padding: 4px 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.gridpilot-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
border-color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.gridpilot-btn:active {
|
||||
background: rgba(255, 255, 255, 0.35);
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
.gridpilot-btn.paused {
|
||||
background: #4e4e57;
|
||||
border-color: #ffffff;
|
||||
color: #ffffff;
|
||||
animation: gridpilot-pulse 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.gridpilot-close-btn {
|
||||
background: rgba(200, 16, 46, 0.6);
|
||||
border-color: rgba(200, 16, 46, 0.8);
|
||||
}
|
||||
|
||||
.gridpilot-close-btn:hover {
|
||||
background: rgba(200, 16, 46, 0.8);
|
||||
border-color: #c8102e;
|
||||
}
|
||||
|
||||
.gridpilot-close-btn:active {
|
||||
background: #c8102e;
|
||||
}
|
||||
|
||||
.gridpilot-header-buttons {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.gridpilot-body {
|
||||
padding: 14px;
|
||||
background: #1a1a24;
|
||||
}
|
||||
|
||||
.gridpilot-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.gridpilot-spinner {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border: 2px solid rgba(200, 16, 46, 0.3);
|
||||
border-top-color: #c8102e;
|
||||
border-radius: 50%;
|
||||
animation: gridpilot-spin 0.8s linear infinite;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.gridpilot-spinner.paused {
|
||||
animation-play-state: paused;
|
||||
border-top-color: #777880;
|
||||
border-color: rgba(119, 120, 128, 0.3);
|
||||
}
|
||||
|
||||
.gridpilot-action-text {
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.gridpilot-progress-container {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.gridpilot-progress-bar {
|
||||
height: 4px;
|
||||
background: rgba(78, 78, 87, 0.5);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.gridpilot-progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #c8102e, #e8304a, #c8102e);
|
||||
background-size: 200% 100%;
|
||||
animation: gridpilot-progress 2s linear infinite;
|
||||
border-radius: 2px;
|
||||
transition: width 0.4s ease-out;
|
||||
}
|
||||
|
||||
.gridpilot-progress-fill.paused {
|
||||
animation-play-state: paused;
|
||||
background: #777880;
|
||||
}
|
||||
|
||||
.gridpilot-step-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.gridpilot-step-text {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.gridpilot-step-count {
|
||||
color: #c8102e;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.gridpilot-personality {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
font-size: 11px;
|
||||
font-style: italic;
|
||||
text-align: center;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid rgba(183, 183, 187, 0.15);
|
||||
}
|
||||
|
||||
.gridpilot-footer {
|
||||
background: #12121B;
|
||||
padding: 8px 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
border-top: 1px solid rgba(183, 183, 187, 0.1);
|
||||
}
|
||||
|
||||
.gridpilot-footer-text {
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.gridpilot-footer-dot {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
background: #c8102e;
|
||||
border-radius: 50%;
|
||||
animation: gridpilot-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.gridpilot-footer-dot.paused {
|
||||
background: #777880;
|
||||
animation: none;
|
||||
}
|
||||
</style></head>
|
||||
|
||||
<body id="IR_I" class="clear-bg chakra-ui-light">
|
||||
|
||||
<noscript><p>This website requires javascript and cookies to be enabled to use.</p></noscript>
|
||||
|
||||
<div id="app"><script id="chakra-script">!(function(){try{var a=function(c){var v="(prefers-color-scheme: dark)",h=window.matchMedia(v).matches?"dark":"light",r=c==="system"?h:c,o=document.documentElement,s=document.body,l="chakra-ui-light",d="chakra-ui-dark",i=r==="dark";return s.classList.add(i?d:l),s.classList.remove(i?l:d),o.style.colorScheme=r,o.dataset.theme=r,r},n=a,m="light",e="chakra-ui-color-mode",t=localStorage.getItem(e);t?a(t):localStorage.setItem(e,a(m))}catch(a){}})();</script><div class="css-3klkag"><canvas id="backgrounds" class="background-image" height="1080" width="1920" style="height: 100%; left: 0px; position: fixed; top: 0px; width: 100%; z-index: -1;"></canvas><div class="css-fzhj15"><div class="css-gmuwbf"><div class="chakra-stack css-dk69dq"><div class="css-3gbbd7"><span tabindex="0" class="css-1baulvz"><div class="css-155xtsn"><svg viewBox="0 0 305 56" width="305px" height="56px"><polygon fill="#184C91" points="102.1,19.2 89.6,19.2 80.6,39 93,39 "></polygon><polygon fill="#184C91" points="105.3,12.1 92.9,12.1 90.7,16.9 103.2,16.9 "></polygon><polygon fill="#184C91" points="226.5,19.2 214.1,19.2 205,39 217.4,39 "></polygon><polygon fill="#184C91" points="229.8,12.1 217.3,12.1 215.2,16.9 227.6,16.9 "></polygon><path fill="#184C91" d="M242.5,21.6h6.2l-8,17.4h12.4l6.2-13.4c1.6-3.5,0.1-6.3-3.3-6.3h-24.9L222.1,39h12.4L242.5,21.6z"></path><path fill="#184C91" d="M167.4,19.2h-21.8l-1.1,2.4h15.6L159,24h-12.4c-2,0-4.1,0.9-5.9,2.4c-1.4,1.1-2.5,2.5-3.2,4l-1.1,2.4
|
||||
c-1.6,3.5-0.1,6.3,3.3,6.3h24.9l6.2-13.4C172.3,22.1,170.8,19.2,167.4,19.2z M153.2,36.6H147l4.7-10.3h6.2L153.2,36.6z"></path><path fill="#184C91" d="M175.4,39h18.7c3.4,0,7.5-2.8,9.1-6.3l0.4-0.8h-12.4l-2.2,4.7h-6.2l6.9-15h6.2l-2.2,4.7h12.4l0.4-0.8
|
||||
c1.6-3.5,0.1-6.3-3.3-6.3h-18.7c-2,0-4.1,0.9-5.9,2.4c-1.4,1.1-2.5,2.5-3.2,4l-3.3,7.1C170.5,36.2,172,39,175.4,39z"></path><path fill="#184C91" d="M273.2,19.2L273.2,19.2C273.2,19.2,273.2,19.2,273.2,19.2c-2,0-4.1,0.9-5.9,2.4c-1.4,1.1-2.5,2.5-3.2,4
|
||||
l-3.3,7.1c-0.7,1.5-0.8,2.9-0.4,4c0.5,1.4,1.8,2.4,3.8,2.4h12.4l-1.1,2.4H79.3l-1.1,2.4h202.4c2,0,4.1-0.9,5.9-2.4
|
||||
c1.4-1.1,2.5-2.5,3.2-4l8.3-18.2H273.2z M277.6,36.6h-6.2l6.9-15h6.2L277.6,36.6z"></path><path fill="none" d="M70.2,13.5c0.2-0.3,0.5-0.6,0.7-0.9C70.7,12.9,70.4,13.2,70.2,13.5z"></path><path fill="none" d="M71.4,9.4c-0.1-0.1-0.2-0.2-0.2-0.3C71.2,9.2,71.3,9.3,71.4,9.4z"></path><path fill="#ffffff" d="M4.6,4.3c-0.7,0-1.1,0.4-1.6,1.1C2,6.5,2.3,6.4,2.6,7.2c0.3,0.9,0,1.6,0.8,2c0.4,0.2,0.8-0.3,0.9-0.9
|
||||
C4.6,9,4.8,9.4,5,10.2c0.3,0.4,0.8,1,1.2,0.8c0.7,0,0.9,0.5,1.1,1c-0.2,0.9,0,0.8,0.5,1.4c0.2,0.3,0.4,0.8,0.6,1
|
||||
c0.1,0.1,0.3,0.3,0.4,0.4c0.6,0.7,1,1.5,1.6,2.2c1.1,0.8,1.7,1.4,2.1,2.1c1.7,2.2,2.2,3.4,3.6,4.4c0.3,0.5,1,0.9,1.1,1.6
|
||||
c0.1,1.3,2.3,3.1,3.5,4.3c1.8,1.8,3.6,3.3,5.8,4.7c2,5.5,3.7,12.7,3.2,18.7c-0.3,1.2-0.5,2.2-0.8,3.3h19.8c-0.1-0.5-0.2-1-0.3-1.4
|
||||
c-0.1-0.3-0.2-0.7-0.2-1c0-0.2,0.2-0.4,0.2-0.7c0.4-6.2,2.2-10.3,3.4-15.6c0.2-0.9,0.4-1.7,0.3-2.1c1.4-0.9,2.7-1.5,3.7-3.2
|
||||
c2.1-1.5,3.4-3,3.6-4.5c0.7-0.7,2.3-0.6,2.3-2.1c0.7-0.7,1.1-1.3,1.3-2.1c1-1.3,2.4-2.7,2.5-3.6c1.4-1,2.3-1.7,1.8-2.8
|
||||
c0.8-0.4,1.2-1.2,1-1.9c0.6-0.7,1.7-0.8,2.1-1.5c0.2-0.3,0.5-0.6,0.7-0.9c0.2-0.2,0.4-0.4,0.5-0.6v-1.2c0,0,0-0.1,0-0.1V9.5
|
||||
c0,0,0-0.1-0.1-0.1c-0.1-0.1-0.2-0.2-0.2-0.3c0.1-0.9-0.4-1.3-1.4-1.4c0.1-0.8,0.4-1.8,0.7-2.7c0.4-0.7,0.5-1.2,0.2-1.5
|
||||
c-0.6-0.5-1,0-1.6,1.1c-0.3,0.6-0.7,1.5-1,2.1c-0.4,0.5-0.7,1-1,1.6c-0.6,0.7-1.3,1.8-1.3,2.7c-0.3,0.4-0.4,0.7-0.7,0.9
|
||||
c-0.1-0.1-0.4-0.1-0.6,0.2c-0.1,0.4-0.3,0.6-0.3,0.8c-0.7-0.3-1.2,0.3-1.6,1.5c-0.2,0.1-0.8,0.3-0.8,0.5c-0.3,0.2-0.4,0.5-0.7,0.7
|
||||
c-0.6,0.1-0.7,0.8-1.1,1.2c-0.4,0.1-0.7,0.6-0.9,1.1c-0.5,0-1,0.6-1.2,1.1c-0.9,0.5-1.2,0.9-1.5,1.7c-0.7,0.1-1.5,0.4-1.9,1.2
|
||||
c-0.7,0.4-1.5,1.2-1.8,1.9c-0.7,0.5-1.2,1.2-1.9,2.4c-0.8-0.4-1.7-0.4-2.5,0c-0.6-0.1-1.1-0.2-1.6-0.3c-0.3-0.3-0.3-0.6-0.1-0.9
|
||||
c0-0.3,0-0.7,0-1.1c0.2-0.8,0.5-1.7,0.7-2.5c0.6-1.6,0.6-3,0.4-4.4c0.1-1.3-0.1-2.5-0.6-3.9c-1.5-1.1-3.6-1.9-5.4-1.9
|
||||
c-2.2-0.3-3.8,0-5.3,0.8c-0.8,0.5-1.6,1.1-2.4,1.6c-0.9,0.4-1.7,2.5-2.5,3.8c-0.8,1.8-0.2,2,0.6,1.6h0c-1.5-0.1-0.1-1.4,0.5-1.4
|
||||
c2.1-0.2,4.4-0.2,7.7,1.9c1,0.6,2,1.1,2.9,0.8c0.4,0.5-0.4,1.2-1.4,1.5c-4.7,0.3-9.1,0.3-9.6-0.9c0.2-0.6,0.2-1.3,0.3-1.9
|
||||
c-0.1,0-0.2,0-0.2,0c0.3,0.2-0.1,1.5-0.2,2.3c-0.6,0.4-0.8,1.1-0.6,2c0.2,0.7,0.4,1.3,0.2,2.3c-0.3,0.1-0.6,0.1-0.8,0.1
|
||||
c-0.4-0.2-0.8-0.4-1.2-0.6c-0.6-0.2-1.1-0.1-1.5,0.1c-0.6-0.8-1.2-0.8-1.8-0.7c-0.4-0.3-0.8-0.7-1.2-1c-0.1-1.3-0.7-2.2-2-2.3
|
||||
c-0.5-0.4-1.1-0.6-1.6-1c0-0.3,0-0.7,0-1.1c-0.2-0.7-0.6-1.3-1.6-1.6c-0.8,0.1-1.1-0.7-1.6-1.5c-0.7-0.6-1.3-1.2-1.9-1.7
|
||||
c-0.3-0.5-1-0.7-1.3-1.2c-0.4-0.2-0.7-0.6-1.2-0.8c-0.2-0.2-0.4-0.8-0.8-1.1C13,10.9,12.7,11,12.5,11c-0.2-0.4-0.3-0.4-0.5-0.8
|
||||
c-0.5-0.4-0.9-0.7-1.4-1.1c-0.4-0.5-0.8-0.9-1.2-1.4C9.1,7.5,8.8,7.3,8.5,7C7.9,5.8,6.9,4.6,6.4,3.7C6,3.2,6.2,2.8,5.8,2.3
|
||||
c-0.2-0.3-0.3-0.7-0.5-1L4.7,0.1H4c-0.1,0.2-0.2,0.6-0.2,1C4,1.7,4.2,2.3,4.3,2.8C4.3,3.4,4.6,3.7,4.6,4.3z M31.8,25.6
|
||||
c0.7,3.1,6.4,1.8,12.3,0.2c-2.5,1.2-5,2.4-8.3,2.3C32.8,28,31.2,27.3,31.8,25.6z"></path><path fill="#184C91" d="M71.6,10.6c0,0.1-0.1,0.1-0.1,0.2c0,0.2,0.1,0.4,0.1,0.5V10.6z"></path><path fill="#184C91" d="M35.8,28.1c3.3,0.1,5.9-1.2,8.3-2.3c-5.9,1.6-11.6,2.9-12.3-0.2C31.2,27.3,32.8,28,35.8,28.1z"></path><path fill="#184C91" d="M71.4,9.4C71.4,9.4,71.4,9.4,71.4,9.4L71.4,9.4C71.4,9.4,71.4,9.4,71.4,9.4z"></path><path fill="#184C91" d="M71.5,12L71.5,12c-0.2,0.2-0.3,0.4-0.5,0.6C71.1,12.4,71.3,12.2,71.5,12z"></path><path fill="#184C91" d="M5.3,1.3c0.2,0.3,0.3,0.7,0.5,1L5.3,1.3z"></path><path fill="#184C91" d="M71.6,0h-67c0.1,0,0.1,0,0.1,0.1c0.2,0.4,0.4,0.8,0.6,1.2l0.5,1C6.2,2.8,6,3.2,6.4,3.7c0.5,1,1.5,2.2,2.1,3.4
|
||||
c0.3,0.2,0.6,0.5,0.9,0.7c0.4,0.5,0.8,0.9,1.2,1.4c0.5,0.3,0.9,0.7,1.4,1.1c0.2,0.4,0.3,0.4,0.5,0.8c0.2,0,0.5-0.1,0.8,0.2
|
||||
c0.3,0.3,0.6,0.9,0.8,1.1c0.5,0.2,0.7,0.6,1.2,0.8c0.3,0.5,1,0.7,1.3,1.2c0.7,0.5,1.3,1.2,1.9,1.7c0.5,0.8,0.7,1.6,1.6,1.5
|
||||
c1.1,0.3,1.4,1,1.6,1.6c0,0.3,0,0.7,0,1.1c0.6,0.4,1.1,0.6,1.6,1c1.2,0.1,1.9,1,2,2.3c0.4,0.3,0.8,0.7,1.2,1
|
||||
c0.6-0.1,1.2-0.1,1.8,0.7c0.4-0.2,0.9-0.3,1.5-0.1c0.4,0.2,0.8,0.4,1.2,0.6c0.3-0.1,0.6-0.1,0.8-0.1c0.2-1,0-1.7-0.2-2.3
|
||||
c-0.2-0.9,0-1.5,0.6-2c0.1-0.9,0.5-2.2,0.2-2.3c0.1,0,0.2,0,0.2,0c-0.1,0.6-0.1,1.3-0.3,1.9c0.4,1.2,4.9,1.1,9.6,0.9
|
||||
c1-0.2,1.8-1,1.4-1.5c-0.9,0.3-1.9-0.3-2.9-0.8c-3.3-2.1-5.6-2.1-7.7-1.9c-0.6,0.1-2,1.4-0.5,1.4h0c-0.8,0.4-1.4,0.2-0.6-1.6
|
||||
c0.8-1.3,1.6-3.3,2.5-3.8c0.8-0.5,1.6-1.1,2.4-1.6c1.5-0.8,3.1-1.1,5.3-0.8c1.8,0,3.9,0.9,5.4,1.9c0.6,1.3,0.7,2.5,0.6,3.9
|
||||
c0.2,1.4,0.2,2.9-0.4,4.4c-0.2,0.9-0.5,1.7-0.7,2.5c0,0.4,0,0.7,0,1.1c-0.2,0.3-0.2,0.6,0.1,0.9c0.6,0.1,1.1,0.2,1.6,0.3
|
||||
c0.9-0.4,1.8-0.4,2.5,0c0.7-1.2,1.2-1.9,1.9-2.4c0.3-0.8,1.1-1.5,1.8-1.9c0.4-0.8,1.2-1.1,1.9-1.2c0.3-0.7,0.6-1.1,1.5-1.7
|
||||
c0.2-0.5,0.7-1.1,1.2-1.1c0.2-0.4,0.5-0.9,0.9-1.1c0.4-0.4,0.5-1.1,1.1-1.2c0.3-0.2,0.4-0.5,0.7-0.7c0-0.2,0.6-0.4,0.8-0.5
|
||||
c0.4-1.2,0.9-1.8,1.6-1.5c0-0.3,0.2-0.5,0.3-0.8c0.2-0.3,0.5-0.2,0.6-0.2c0.3-0.2,0.4-0.4,0.7-0.9c0-0.9,0.7-2,1.3-2.7
|
||||
c0.3-0.7,0.6-1.1,1-1.6c0.3-0.6,0.7-1.5,1-2.1c0.6-1.1,1.1-1.7,1.6-1.1c0.3,0.3,0.2,0.8-0.2,1.5c-0.2,0.9-0.6,1.8-0.7,2.7
|
||||
c1,0.1,1.4,0.5,1.4,1.4c0.1,0.1,0.2,0.2,0.2,0.3c0,0,0,0.1,0.1,0.1v0c0,0,0.1,0.1,0.1,0.1L71.6,0z"></path><path fill="#184C91" d="M71.5,12L71.5,12c-0.2,0.2-0.4,0.4-0.5,0.6c-0.3,0.3-0.5,0.6-0.7,0.9c-0.4,0.7-1.5,0.8-2.1,1.5
|
||||
c0.2,0.8-0.2,1.6-1,1.9c0.5,1.1-0.4,1.8-1.8,2.8c-0.1,0.9-1.5,2.3-2.5,3.6c-0.1,0.8-0.5,1.4-1.3,2.1c0,1.5-1.6,1.3-2.3,2.1
|
||||
c-0.2,1.6-1.6,3-3.6,4.5c-0.9,1.7-2.3,2.3-3.7,3.2c0.1,0.4-0.2,1.2-0.3,2.1c-1.2,5.3-2.9,9.4-3.4,15.6c0,0.2-0.2,0.4-0.2,0.7
|
||||
c0,0.3,0.2,0.7,0.2,1c0.1,0.5,0.2,1,0.3,1.4h23l0-44.1C71.5,11.9,71.5,12,71.5,12z"></path><path fill="#D82727" d="M26.4,34c-2.2-1.4-4-2.8-5.8-4.7c-1.2-1.2-3.4-3-3.5-4.3c-0.1-0.7-0.7-1-1.1-1.6c-1.4-1.1-1.9-2.2-3.6-4.4
|
||||
c-0.4-0.7-1-1.3-2.1-2.1c-0.6-0.7-1-1.5-1.6-2.2c-0.1-0.1-0.3-0.3-0.4-0.4c-0.2-0.1-0.4-0.6-0.6-1c-0.5-0.6-0.7-0.5-0.5-1.4
|
||||
c-0.1-0.5-0.3-1-1.1-1c-0.5,0.2-0.9-0.4-1.2-0.8C4.8,9.4,4.6,9,4.3,8.2C4.2,8.8,3.8,9.3,3.4,9.1c-0.8-0.4-0.5-1-0.8-2
|
||||
C2.3,6.4,2,6.5,2.9,5.4c0.6-0.7,0.9-1.1,1.6-1.1c0-0.6-0.3-0.9-0.3-1.4C4.2,2.3,4,1.7,3.8,1.1c0-0.5,0-1,0.3-1.1H0v56h28.8
|
||||
c0.3-1.1,0.5-2.1,0.8-3.3C30.1,46.7,28.4,39.5,26.4,34z"></path><path fill="#184C91" d="M299.9,17.8c0-0.2,0-0.3,0.1-0.5c0-0.2,0.1-0.3,0.2-0.4c0.1-0.1,0.2-0.3,0.3-0.4c0.1-0.1,0.2-0.2,0.4-0.3
|
||||
c0.1-0.1,0.3-0.1,0.4-0.2c0.2,0,0.3-0.1,0.5-0.1c0.2,0,0.3,0,0.5,0.1c0.2,0,0.3,0.1,0.4,0.2c0.1,0.1,0.3,0.2,0.4,0.3
|
||||
c0.1,0.1,0.2,0.2,0.3,0.4c0.1,0.1,0.1,0.3,0.2,0.4c0,0.2,0.1,0.3,0.1,0.5c0,0.2,0,0.3-0.1,0.5c0,0.2-0.1,0.3-0.2,0.4
|
||||
c-0.1,0.1-0.2,0.3-0.3,0.4c-0.1,0.1-0.2,0.2-0.4,0.3c-0.1,0.1-0.3,0.1-0.4,0.2s-0.3,0.1-0.5,0.1c-0.2,0-0.3,0-0.5-0.1
|
||||
c-0.2,0-0.3-0.1-0.4-0.2c-0.1-0.1-0.3-0.2-0.4-0.3c-0.1-0.1-0.2-0.2-0.3-0.4c-0.1-0.1-0.1-0.3-0.2-0.4
|
||||
C299.9,18.1,299.9,18,299.9,17.8z M300.2,17.8c0,0.2,0,0.4,0.1,0.6c0.1,0.2,0.2,0.3,0.3,0.5c0.1,0.1,0.3,0.2,0.5,0.3
|
||||
c0.2,0.1,0.4,0.1,0.6,0.1c0.2,0,0.4,0,0.6-0.1c0.2-0.1,0.3-0.2,0.5-0.3c0.1-0.1,0.2-0.3,0.3-0.5c0.1-0.2,0.1-0.4,0.1-0.6
|
||||
c0-0.1,0-0.3-0.1-0.4c0-0.1-0.1-0.2-0.2-0.4c-0.1-0.1-0.1-0.2-0.2-0.3c-0.1-0.1-0.2-0.2-0.3-0.2c-0.1-0.1-0.2-0.1-0.4-0.1
|
||||
c-0.1,0-0.3,0-0.4,0c-0.1,0-0.3,0-0.4,0.1c-0.1,0-0.2,0.1-0.3,0.2c-0.1,0.1-0.2,0.1-0.3,0.2c-0.1,0.1-0.2,0.2-0.2,0.3
|
||||
c-0.1,0.1-0.1,0.2-0.1,0.4C300.2,17.5,300.2,17.6,300.2,17.8z M301.4,18.1l0,0.8l-0.5,0l0-2.2l0.8,0c0.3,0,0.5,0,0.7,0.1
|
||||
c0.1,0.1,0.2,0.3,0.2,0.5c0,0.1,0,0.3-0.1,0.4c-0.1,0.1-0.2,0.2-0.3,0.2c0,0,0.1,0,0.1,0.1c0,0,0,0.1,0.1,0.1l0.5,0.7l-0.5,0
|
||||
c-0.1,0-0.1,0-0.2-0.1l-0.4-0.6c0,0,0,0-0.1-0.1c0,0-0.1,0-0.1,0L301.4,18.1z M301.4,17.7l0.2,0c0.1,0,0.2,0,0.2,0
|
||||
c0.1,0,0.1,0,0.1-0.1c0,0,0-0.1,0.1-0.1c0,0,0-0.1,0-0.1c0-0.1,0-0.1,0-0.1c0,0,0-0.1-0.1-0.1c0,0-0.1,0-0.1-0.1c0,0-0.1,0-0.2,0
|
||||
l-0.3,0L301.4,17.7z"></path><path fill="#184C91" d="M110.1,39l3.3-7.1h0.8l2.2,7.1h12.4l-2.2-7.2c3.3-0.4,6.9-3,8.4-6.3l3.3-7.1c1.6-3.5,0.1-6.3-3.3-6.3H110
|
||||
L97.7,39H110.1z M121.4,14.5h6.2l-6.9,15h-6.2L121.4,14.5z"></path></svg></div></span></div><p class="chakra-text css-1mud8qf">You are not logged in.</p><div role="group" class="chakra-button__group chakra-stack css-4jt4m7" data-orientation="horizontal"><button type="button" class="chakra-button css-h9kfy" aria-label="Log in" tabindex="0"><span class="chakra-button__icon css-1wh2kri"><svg viewBox="0 0 16 16" focusable="false" class="chakra-icon css-onkibi" aria-hidden="true"><path d="M11.9999 1.5H9.74993C9.33571 1.5 8.99993 1.16421 8.99993 0.75C8.99993 0.335786 9.33571 0 9.74993 0H11.9999C13.1045 0 13.9999 0.895431 13.9999 2V14C13.9999 15.1046 13.1045 16 11.9999 16H9.74993C9.33571 16 8.99993 15.6642 8.99993 15.25C8.99993 14.8358 9.33571 14.5 9.74993 14.5H11.9999C12.2761 14.5 12.4999 14.2761 12.4999 14V2C12.4999 1.72386 12.2761 1.5 11.9999 1.5Z" fill="currentColor"></path><path d="M6.9267 4.43945C7.21959 4.14656 7.69447 4.14656 7.98736 4.43945L10.6338 7.0859C11.122 7.57406 11.122 8.36551 10.6338 8.85367L7.98736 11.5001C7.69447 11.793 7.21959 11.793 6.9267 11.5001C6.63381 11.2072 6.63381 10.7323 6.9267 10.4395L8.64637 8.71978L2.75 8.71978C2.33579 8.71978 2 8.384 2 7.96978C2 7.55557 2.33579 7.21978 2.75 7.21978L8.64637 7.21978L6.9267 5.50011C6.63381 5.20722 6.63381 4.73235 6.9267 4.43945Z" fill="currentColor"></path></svg></span>Log in</button></div></div></div></div></div><span id="__chakra_env" hidden=""></span></div>
|
||||
|
||||
<script nonce="">
|
||||
// Jumpstart theme
|
||||
(function load() {
|
||||
const cssCommitish = '0e20cfa'
|
||||
const environment = 'members'
|
||||
var chakraTheme = localStorage.getItem("chakra-ui-color-mode");
|
||||
var cookieTheme = document.cookie.replace(/(?:(?:^|.*;\s*)theme\s*=\s*([^;]*).*$)|^.*$/, "$1");
|
||||
var theme = chakraTheme || cookieTheme || "light";
|
||||
|
||||
// Browser bar style
|
||||
var meta = document.createElement("meta");
|
||||
meta.setAttribute("name", "theme-color");
|
||||
meta.setAttribute("content", theme === "dark" ? "#05050F" : "#CDCDCF");
|
||||
document.getElementsByTagName("head")[0].appendChild(meta);
|
||||
|
||||
if (theme === "dark") {
|
||||
document.documentElement.setAttribute("data-theme", "dark");
|
||||
document.querySelector(`link[href="https://${environment}-assets.iracing.com/public/shared-css/${cssCommitish}/styles/light.min.css"]`).href = `https://${environment}-assets.iracing.com/public/shared-css/${cssCommitish}/styles/dark.min.css`;
|
||||
localStorage.setItem("chakra-ui-color-mode", "dark");
|
||||
} else {
|
||||
document.documentElement.setAttribute("data-theme", "light");
|
||||
localStorage.setItem("chakra-ui-color-mode", "light");
|
||||
}
|
||||
})();
|
||||
|
||||
// Tag Manager
|
||||
(function() {
|
||||
const tagFrame = document.createElement("iframe");
|
||||
tagFrame.style.display = "none";
|
||||
tagFrame.style.visibility = "hidden";
|
||||
document.body.appendChild(tagFrame);
|
||||
tagFrame.setAttribute("src", "https://www.googletagmanager.com/ns.html?id=GTM-TQBRJCCM");
|
||||
})();
|
||||
|
||||
</script><iframe src="https://www.googletagmanager.com/ns.html?id=GTM-TQBRJCCM" style="display: none; visibility: hidden;"></iframe>
|
||||
|
||||
<script src="/web/js/features.06672f47edf44cc1.min.js" nonce=""></script>
|
||||
|
||||
<script src="/web/js/modals.ae8419431eceaffb.min.js" nonce=""></script>
|
||||
|
||||
<script src="/web/js/partials.5bbc922034ab9879.min.js" nonce=""></script>
|
||||
|
||||
<script src="/web/js/content.444652900591a9f4.min.js" nonce=""></script>
|
||||
|
||||
<script src="/web/js/pages.9bce6de2e3069f68.min.js" nonce=""></script>
|
||||
|
||||
<script src="/web/js/cards.9166c4c695a3fd3c.min.js" nonce=""></script>
|
||||
|
||||
<script src="/web/js/echarts.0fde742e8e70602d.min.js" nonce=""></script>
|
||||
|
||||
<script src="/web/js/moment.4ea910c71253b76a.min.js" nonce=""></script>
|
||||
|
||||
<script src="/web/js/interface.b0697a5bfaa16f8e.min.js" nonce=""></script>
|
||||
|
||||
<script src="/web/js/vendor.893c2f1bd68de2a3.min.js" nonce=""></script>
|
||||
|
||||
<script src="/web/js/main.bccb09d6090f2763.min.js" nonce=""></script><div class="chakra-portal"><div role="region" aria-live="polite" aria-label="Notifications-top" id="chakra-toast-manager-top" style="position: fixed; z-index: var(--toast-z-index, 5500); pointer-events: none; display: flex; flex-direction: column; margin: 0px auto; top: env(safe-area-inset-top, 0px); right: env(safe-area-inset-right, 0px); left: env(safe-area-inset-left, 0px);"></div><div role="region" aria-live="polite" aria-label="Notifications-top-left" id="chakra-toast-manager-top-left" style="position: fixed; z-index: var(--toast-z-index, 5500); pointer-events: none; display: flex; flex-direction: column; top: env(safe-area-inset-top, 0px); left: env(safe-area-inset-left, 0px);"></div><div role="region" aria-live="polite" aria-label="Notifications-top-right" id="chakra-toast-manager-top-right" style="position: fixed; z-index: var(--toast-z-index, 5500); pointer-events: none; display: flex; flex-direction: column; top: env(safe-area-inset-top, 0px); right: env(safe-area-inset-right, 0px);"></div><div role="region" aria-live="polite" aria-label="Notifications-bottom-left" id="chakra-toast-manager-bottom-left" style="position: fixed; z-index: var(--toast-z-index, 5500); pointer-events: none; display: flex; flex-direction: column; bottom: env(safe-area-inset-bottom, 0px); left: env(safe-area-inset-left, 0px);"></div><div role="region" aria-live="polite" aria-label="Notifications-bottom" id="chakra-toast-manager-bottom" style="position: fixed; z-index: var(--toast-z-index, 5500); pointer-events: none; display: flex; flex-direction: column; margin: 0px auto; bottom: env(safe-area-inset-bottom, 0px); right: env(safe-area-inset-right, 0px); left: env(safe-area-inset-left, 0px);"></div><div role="region" aria-live="polite" aria-label="Notifications-bottom-right" id="chakra-toast-manager-bottom-right" style="position: fixed; z-index: var(--toast-z-index, 5500); pointer-events: none; display: flex; flex-direction: column; bottom: env(safe-area-inset-bottom, 0px); right: env(safe-area-inset-right, 0px);"></div></div><div class="chakra-portal"><div role="region" aria-live="polite" aria-label="Notifications-top" id="chakra-toast-manager-top" style="position: fixed; z-index: var(--toast-z-index, 5500); pointer-events: none; display: flex; flex-direction: column; margin: 0px auto; top: env(safe-area-inset-top, 0px); right: env(safe-area-inset-right, 0px); left: env(safe-area-inset-left, 0px);"></div><div role="region" aria-live="polite" aria-label="Notifications-top-left" id="chakra-toast-manager-top-left" style="position: fixed; z-index: var(--toast-z-index, 5500); pointer-events: none; display: flex; flex-direction: column; top: env(safe-area-inset-top, 0px); left: env(safe-area-inset-left, 0px);"></div><div role="region" aria-live="polite" aria-label="Notifications-top-right" id="chakra-toast-manager-top-right" style="position: fixed; z-index: var(--toast-z-index, 5500); pointer-events: none; display: flex; flex-direction: column; top: env(safe-area-inset-top, 0px); right: env(safe-area-inset-right, 0px);"></div><div role="region" aria-live="polite" aria-label="Notifications-bottom-left" id="chakra-toast-manager-bottom-left" style="position: fixed; z-index: var(--toast-z-index, 5500); pointer-events: none; display: flex; flex-direction: column; bottom: env(safe-area-inset-bottom, 0px); left: env(safe-area-inset-left, 0px);"></div><div role="region" aria-live="polite" aria-label="Notifications-bottom" id="chakra-toast-manager-bottom" style="position: fixed; z-index: var(--toast-z-index, 5500); pointer-events: none; display: flex; flex-direction: column; margin: 0px auto; bottom: env(safe-area-inset-bottom, 0px); right: env(safe-area-inset-right, 0px); left: env(safe-area-inset-left, 0px);"></div><div role="region" aria-live="polite" aria-label="Notifications-bottom-right" id="chakra-toast-manager-bottom-right" style="position: fixed; z-index: var(--toast-z-index, 5500); pointer-events: none; display: flex; flex-direction: column; bottom: env(safe-area-inset-bottom, 0px); right: env(safe-area-inset-right, 0px);"></div></div>
|
||||
|
||||
|
||||
<div id="gridpilot-overlay">
|
||||
<div class="gridpilot-card">
|
||||
<div class="gridpilot-header">
|
||||
<span class="gridpilot-logo">⚠️</span>
|
||||
<span class="gridpilot-title">GridPilot</span>
|
||||
<div class="gridpilot-header-buttons">
|
||||
<button class="gridpilot-btn gridpilot-close-btn" id="gridpilot-close-btn" onclick="(function() {
|
||||
window.__gridpilot_close_requested = true;
|
||||
})()">Stop</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="gridpilot-body">
|
||||
<div class="gridpilot-status">
|
||||
<div class="gridpilot-spinner" style="display: none;"></div>
|
||||
<span class="gridpilot-action-text" id="gridpilot-action">❌ Failed at step 2</span>
|
||||
</div>
|
||||
<div class="gridpilot-progress-container">
|
||||
<div class="gridpilot-progress-bar">
|
||||
<div class="gridpilot-progress-fill" id="gridpilot-progress" style="width: 12%;"></div>
|
||||
</div>
|
||||
<div class="gridpilot-step-info">
|
||||
<span class="gridpilot-step-text" id="gridpilot-step-text">🏁 Creating your race session...</span>
|
||||
<span class="gridpilot-step-count" id="gridpilot-step-count">Stopped</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="gridpilot-personality" id="gridpilot-personality">🔧 Check the error and try again.</div>
|
||||
</div>
|
||||
<div class="gridpilot-footer">
|
||||
<div class="gridpilot-footer-dot"></div>
|
||||
<span class="gridpilot-footer-text">Automating your session setup</span>
|
||||
</div>
|
||||
</div>
|
||||
</div></body></html>
|
||||
|
After Width: | Height: | Size: 265 KiB |
@@ -0,0 +1,467 @@
|
||||
<!DOCTYPE html><html lang="en" data-theme="light" style="color-scheme: light;"><head>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
||||
<meta http-equiv="Content-Security-Policy" content="script-src 'unsafe-eval' http://127.0.0.1:32034 'nonce-0U8XlZNPYvqkA9ET6vpDMzmoEQKF8k80' 'strict-dynamic'">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<link rel="stylesheet" href="https://members-assets.iracing.com/public/shared-css/0e20cfa/styles/light.min.css">
|
||||
<link rel="icon" type="image/png" href="//images-static.iracing.com/favicon.png">
|
||||
<script async="" src="https://www.googletagmanager.com/gtm.js?id=GTM-TQBRJCCM"></script><script src="https://embed.twitch.tv/embed/v1.js" nonce=""></script>
|
||||
|
||||
<script nonce="">(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
|
||||
new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
|
||||
j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
|
||||
'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
|
||||
})(window,document,'script','dataLayer','GTM-TQBRJCCM');</script>
|
||||
|
||||
|
||||
<meta name="theme-color" content="#CDCDCF"><style type="text/css">.indiana-scroll-container {
|
||||
overflow: auto;
|
||||
}
|
||||
.indiana-scroll-container--dragging {
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
.indiana-scroll-container--dragging > * {
|
||||
pointer-events: none;
|
||||
cursor: -webkit-grab;
|
||||
cursor: grab;
|
||||
}
|
||||
.indiana-scroll-container--hide-scrollbars {
|
||||
overflow: hidden;
|
||||
overflow: -moz-scrollbars-none;
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.indiana-scroll-container--hide-scrollbars::-webkit-scrollbar {
|
||||
display: none !important;
|
||||
height: 0 !important;
|
||||
width: 0 !important;
|
||||
background: transparent !important;
|
||||
-webkit-appearance: none !important;
|
||||
}
|
||||
.indiana-scroll-container--native-scroll {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.indiana-dragging {
|
||||
cursor: -webkit-grab;
|
||||
cursor: grab;
|
||||
}</style><style data-emotion="css-global" data-s=""></style><style data-emotion="css-global" data-s=""></style><style data-emotion="css-global" data-s=""></style><style data-emotion="css-global" data-s=""></style><style data-emotion="css-global" data-s=""></style><style data-emotion="css-global" data-s=""></style><style data-emotion="css-global" data-s=""></style><style data-emotion="css" data-s=""></style><style type="text/css">
|
||||
@keyframes gridpilot-pulse {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.85; transform: scale(1.03); }
|
||||
}
|
||||
|
||||
@keyframes gridpilot-spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@keyframes gridpilot-slide-in {
|
||||
from { transform: translateX(100%); opacity: 0; }
|
||||
to { transform: translateX(0); opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes gridpilot-checkered {
|
||||
0% { background-position: 0 0; }
|
||||
100% { background-position: 20px 20px; }
|
||||
}
|
||||
|
||||
@keyframes gridpilot-progress {
|
||||
0% { background-position: 0% 50%; }
|
||||
100% { background-position: 100% 50%; }
|
||||
}
|
||||
|
||||
#gridpilot-overlay {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
width: 340px;
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
z-index: 2147483647;
|
||||
animation: gridpilot-slide-in 0.4s ease-out;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
#gridpilot-overlay * {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.gridpilot-card {
|
||||
background: #12121B;
|
||||
border-radius: 4px;
|
||||
border: 1px solid rgba(183, 183, 187, 0.2);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.gridpilot-header {
|
||||
background: linear-gradient(90deg, #c8102e 0%, #a00d25 100%);
|
||||
padding: 10px 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.gridpilot-header::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background:
|
||||
linear-gradient(45deg, transparent 48%, rgba(255,255,255,0.05) 49%, rgba(255,255,255,0.05) 51%, transparent 52%),
|
||||
linear-gradient(-45deg, transparent 48%, rgba(255,255,255,0.05) 49%, rgba(255,255,255,0.05) 51%, transparent 52%);
|
||||
background-size: 8px 8px;
|
||||
animation: gridpilot-checkered 1.5s linear infinite;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.gridpilot-logo {
|
||||
font-size: 22px;
|
||||
animation: gridpilot-pulse 2s ease-in-out infinite;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.gridpilot-title {
|
||||
color: #ffffff;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1.5px;
|
||||
text-transform: uppercase;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
text-shadow: 0 1px 2px rgba(0,0,0,0.3);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.gridpilot-btn {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
color: #ffffff;
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 3px;
|
||||
padding: 4px 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.gridpilot-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
border-color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.gridpilot-btn:active {
|
||||
background: rgba(255, 255, 255, 0.35);
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
.gridpilot-btn.paused {
|
||||
background: #4e4e57;
|
||||
border-color: #ffffff;
|
||||
color: #ffffff;
|
||||
animation: gridpilot-pulse 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.gridpilot-close-btn {
|
||||
background: rgba(200, 16, 46, 0.6);
|
||||
border-color: rgba(200, 16, 46, 0.8);
|
||||
}
|
||||
|
||||
.gridpilot-close-btn:hover {
|
||||
background: rgba(200, 16, 46, 0.8);
|
||||
border-color: #c8102e;
|
||||
}
|
||||
|
||||
.gridpilot-close-btn:active {
|
||||
background: #c8102e;
|
||||
}
|
||||
|
||||
.gridpilot-header-buttons {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.gridpilot-body {
|
||||
padding: 14px;
|
||||
background: #1a1a24;
|
||||
}
|
||||
|
||||
.gridpilot-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.gridpilot-spinner {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border: 2px solid rgba(200, 16, 46, 0.3);
|
||||
border-top-color: #c8102e;
|
||||
border-radius: 50%;
|
||||
animation: gridpilot-spin 0.8s linear infinite;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.gridpilot-spinner.paused {
|
||||
animation-play-state: paused;
|
||||
border-top-color: #777880;
|
||||
border-color: rgba(119, 120, 128, 0.3);
|
||||
}
|
||||
|
||||
.gridpilot-action-text {
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.gridpilot-progress-container {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.gridpilot-progress-bar {
|
||||
height: 4px;
|
||||
background: rgba(78, 78, 87, 0.5);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.gridpilot-progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #c8102e, #e8304a, #c8102e);
|
||||
background-size: 200% 100%;
|
||||
animation: gridpilot-progress 2s linear infinite;
|
||||
border-radius: 2px;
|
||||
transition: width 0.4s ease-out;
|
||||
}
|
||||
|
||||
.gridpilot-progress-fill.paused {
|
||||
animation-play-state: paused;
|
||||
background: #777880;
|
||||
}
|
||||
|
||||
.gridpilot-step-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.gridpilot-step-text {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.gridpilot-step-count {
|
||||
color: #c8102e;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.gridpilot-personality {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
font-size: 11px;
|
||||
font-style: italic;
|
||||
text-align: center;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid rgba(183, 183, 187, 0.15);
|
||||
}
|
||||
|
||||
.gridpilot-footer {
|
||||
background: #12121B;
|
||||
padding: 8px 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
border-top: 1px solid rgba(183, 183, 187, 0.1);
|
||||
}
|
||||
|
||||
.gridpilot-footer-text {
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.gridpilot-footer-dot {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
background: #c8102e;
|
||||
border-radius: 50%;
|
||||
animation: gridpilot-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.gridpilot-footer-dot.paused {
|
||||
background: #777880;
|
||||
animation: none;
|
||||
}
|
||||
</style></head>
|
||||
|
||||
<body id="IR_I" class="clear-bg chakra-ui-light">
|
||||
|
||||
<noscript><p>This website requires javascript and cookies to be enabled to use.</p></noscript>
|
||||
|
||||
<div id="app"><script id="chakra-script">!(function(){try{var a=function(c){var v="(prefers-color-scheme: dark)",h=window.matchMedia(v).matches?"dark":"light",r=c==="system"?h:c,o=document.documentElement,s=document.body,l="chakra-ui-light",d="chakra-ui-dark",i=r==="dark";return s.classList.add(i?d:l),s.classList.remove(i?l:d),o.style.colorScheme=r,o.dataset.theme=r,r},n=a,m="light",e="chakra-ui-color-mode",t=localStorage.getItem(e);t?a(t):localStorage.setItem(e,a(m))}catch(a){}})();</script><div class="css-3klkag"><canvas id="backgrounds" class="background-image" height="1080" width="1920" style="height: 100%; left: 0px; position: fixed; top: 0px; width: 100%; z-index: -1;"></canvas><div class="css-fzhj15"><div class="css-gmuwbf"><div class="chakra-stack css-dk69dq"><div class="css-3gbbd7"><span tabindex="0" class="css-1baulvz"><div class="css-155xtsn"><svg viewBox="0 0 305 56" width="305px" height="56px"><polygon fill="#184C91" points="102.1,19.2 89.6,19.2 80.6,39 93,39 "></polygon><polygon fill="#184C91" points="105.3,12.1 92.9,12.1 90.7,16.9 103.2,16.9 "></polygon><polygon fill="#184C91" points="226.5,19.2 214.1,19.2 205,39 217.4,39 "></polygon><polygon fill="#184C91" points="229.8,12.1 217.3,12.1 215.2,16.9 227.6,16.9 "></polygon><path fill="#184C91" d="M242.5,21.6h6.2l-8,17.4h12.4l6.2-13.4c1.6-3.5,0.1-6.3-3.3-6.3h-24.9L222.1,39h12.4L242.5,21.6z"></path><path fill="#184C91" d="M167.4,19.2h-21.8l-1.1,2.4h15.6L159,24h-12.4c-2,0-4.1,0.9-5.9,2.4c-1.4,1.1-2.5,2.5-3.2,4l-1.1,2.4
|
||||
c-1.6,3.5-0.1,6.3,3.3,6.3h24.9l6.2-13.4C172.3,22.1,170.8,19.2,167.4,19.2z M153.2,36.6H147l4.7-10.3h6.2L153.2,36.6z"></path><path fill="#184C91" d="M175.4,39h18.7c3.4,0,7.5-2.8,9.1-6.3l0.4-0.8h-12.4l-2.2,4.7h-6.2l6.9-15h6.2l-2.2,4.7h12.4l0.4-0.8
|
||||
c1.6-3.5,0.1-6.3-3.3-6.3h-18.7c-2,0-4.1,0.9-5.9,2.4c-1.4,1.1-2.5,2.5-3.2,4l-3.3,7.1C170.5,36.2,172,39,175.4,39z"></path><path fill="#184C91" d="M273.2,19.2L273.2,19.2C273.2,19.2,273.2,19.2,273.2,19.2c-2,0-4.1,0.9-5.9,2.4c-1.4,1.1-2.5,2.5-3.2,4
|
||||
l-3.3,7.1c-0.7,1.5-0.8,2.9-0.4,4c0.5,1.4,1.8,2.4,3.8,2.4h12.4l-1.1,2.4H79.3l-1.1,2.4h202.4c2,0,4.1-0.9,5.9-2.4
|
||||
c1.4-1.1,2.5-2.5,3.2-4l8.3-18.2H273.2z M277.6,36.6h-6.2l6.9-15h6.2L277.6,36.6z"></path><path fill="none" d="M70.2,13.5c0.2-0.3,0.5-0.6,0.7-0.9C70.7,12.9,70.4,13.2,70.2,13.5z"></path><path fill="none" d="M71.4,9.4c-0.1-0.1-0.2-0.2-0.2-0.3C71.2,9.2,71.3,9.3,71.4,9.4z"></path><path fill="#ffffff" d="M4.6,4.3c-0.7,0-1.1,0.4-1.6,1.1C2,6.5,2.3,6.4,2.6,7.2c0.3,0.9,0,1.6,0.8,2c0.4,0.2,0.8-0.3,0.9-0.9
|
||||
C4.6,9,4.8,9.4,5,10.2c0.3,0.4,0.8,1,1.2,0.8c0.7,0,0.9,0.5,1.1,1c-0.2,0.9,0,0.8,0.5,1.4c0.2,0.3,0.4,0.8,0.6,1
|
||||
c0.1,0.1,0.3,0.3,0.4,0.4c0.6,0.7,1,1.5,1.6,2.2c1.1,0.8,1.7,1.4,2.1,2.1c1.7,2.2,2.2,3.4,3.6,4.4c0.3,0.5,1,0.9,1.1,1.6
|
||||
c0.1,1.3,2.3,3.1,3.5,4.3c1.8,1.8,3.6,3.3,5.8,4.7c2,5.5,3.7,12.7,3.2,18.7c-0.3,1.2-0.5,2.2-0.8,3.3h19.8c-0.1-0.5-0.2-1-0.3-1.4
|
||||
c-0.1-0.3-0.2-0.7-0.2-1c0-0.2,0.2-0.4,0.2-0.7c0.4-6.2,2.2-10.3,3.4-15.6c0.2-0.9,0.4-1.7,0.3-2.1c1.4-0.9,2.7-1.5,3.7-3.2
|
||||
c2.1-1.5,3.4-3,3.6-4.5c0.7-0.7,2.3-0.6,2.3-2.1c0.7-0.7,1.1-1.3,1.3-2.1c1-1.3,2.4-2.7,2.5-3.6c1.4-1,2.3-1.7,1.8-2.8
|
||||
c0.8-0.4,1.2-1.2,1-1.9c0.6-0.7,1.7-0.8,2.1-1.5c0.2-0.3,0.5-0.6,0.7-0.9c0.2-0.2,0.4-0.4,0.5-0.6v-1.2c0,0,0-0.1,0-0.1V9.5
|
||||
c0,0,0-0.1-0.1-0.1c-0.1-0.1-0.2-0.2-0.2-0.3c0.1-0.9-0.4-1.3-1.4-1.4c0.1-0.8,0.4-1.8,0.7-2.7c0.4-0.7,0.5-1.2,0.2-1.5
|
||||
c-0.6-0.5-1,0-1.6,1.1c-0.3,0.6-0.7,1.5-1,2.1c-0.4,0.5-0.7,1-1,1.6c-0.6,0.7-1.3,1.8-1.3,2.7c-0.3,0.4-0.4,0.7-0.7,0.9
|
||||
c-0.1-0.1-0.4-0.1-0.6,0.2c-0.1,0.4-0.3,0.6-0.3,0.8c-0.7-0.3-1.2,0.3-1.6,1.5c-0.2,0.1-0.8,0.3-0.8,0.5c-0.3,0.2-0.4,0.5-0.7,0.7
|
||||
c-0.6,0.1-0.7,0.8-1.1,1.2c-0.4,0.1-0.7,0.6-0.9,1.1c-0.5,0-1,0.6-1.2,1.1c-0.9,0.5-1.2,0.9-1.5,1.7c-0.7,0.1-1.5,0.4-1.9,1.2
|
||||
c-0.7,0.4-1.5,1.2-1.8,1.9c-0.7,0.5-1.2,1.2-1.9,2.4c-0.8-0.4-1.7-0.4-2.5,0c-0.6-0.1-1.1-0.2-1.6-0.3c-0.3-0.3-0.3-0.6-0.1-0.9
|
||||
c0-0.3,0-0.7,0-1.1c0.2-0.8,0.5-1.7,0.7-2.5c0.6-1.6,0.6-3,0.4-4.4c0.1-1.3-0.1-2.5-0.6-3.9c-1.5-1.1-3.6-1.9-5.4-1.9
|
||||
c-2.2-0.3-3.8,0-5.3,0.8c-0.8,0.5-1.6,1.1-2.4,1.6c-0.9,0.4-1.7,2.5-2.5,3.8c-0.8,1.8-0.2,2,0.6,1.6h0c-1.5-0.1-0.1-1.4,0.5-1.4
|
||||
c2.1-0.2,4.4-0.2,7.7,1.9c1,0.6,2,1.1,2.9,0.8c0.4,0.5-0.4,1.2-1.4,1.5c-4.7,0.3-9.1,0.3-9.6-0.9c0.2-0.6,0.2-1.3,0.3-1.9
|
||||
c-0.1,0-0.2,0-0.2,0c0.3,0.2-0.1,1.5-0.2,2.3c-0.6,0.4-0.8,1.1-0.6,2c0.2,0.7,0.4,1.3,0.2,2.3c-0.3,0.1-0.6,0.1-0.8,0.1
|
||||
c-0.4-0.2-0.8-0.4-1.2-0.6c-0.6-0.2-1.1-0.1-1.5,0.1c-0.6-0.8-1.2-0.8-1.8-0.7c-0.4-0.3-0.8-0.7-1.2-1c-0.1-1.3-0.7-2.2-2-2.3
|
||||
c-0.5-0.4-1.1-0.6-1.6-1c0-0.3,0-0.7,0-1.1c-0.2-0.7-0.6-1.3-1.6-1.6c-0.8,0.1-1.1-0.7-1.6-1.5c-0.7-0.6-1.3-1.2-1.9-1.7
|
||||
c-0.3-0.5-1-0.7-1.3-1.2c-0.4-0.2-0.7-0.6-1.2-0.8c-0.2-0.2-0.4-0.8-0.8-1.1C13,10.9,12.7,11,12.5,11c-0.2-0.4-0.3-0.4-0.5-0.8
|
||||
c-0.5-0.4-0.9-0.7-1.4-1.1c-0.4-0.5-0.8-0.9-1.2-1.4C9.1,7.5,8.8,7.3,8.5,7C7.9,5.8,6.9,4.6,6.4,3.7C6,3.2,6.2,2.8,5.8,2.3
|
||||
c-0.2-0.3-0.3-0.7-0.5-1L4.7,0.1H4c-0.1,0.2-0.2,0.6-0.2,1C4,1.7,4.2,2.3,4.3,2.8C4.3,3.4,4.6,3.7,4.6,4.3z M31.8,25.6
|
||||
c0.7,3.1,6.4,1.8,12.3,0.2c-2.5,1.2-5,2.4-8.3,2.3C32.8,28,31.2,27.3,31.8,25.6z"></path><path fill="#184C91" d="M71.6,10.6c0,0.1-0.1,0.1-0.1,0.2c0,0.2,0.1,0.4,0.1,0.5V10.6z"></path><path fill="#184C91" d="M35.8,28.1c3.3,0.1,5.9-1.2,8.3-2.3c-5.9,1.6-11.6,2.9-12.3-0.2C31.2,27.3,32.8,28,35.8,28.1z"></path><path fill="#184C91" d="M71.4,9.4C71.4,9.4,71.4,9.4,71.4,9.4L71.4,9.4C71.4,9.4,71.4,9.4,71.4,9.4z"></path><path fill="#184C91" d="M71.5,12L71.5,12c-0.2,0.2-0.3,0.4-0.5,0.6C71.1,12.4,71.3,12.2,71.5,12z"></path><path fill="#184C91" d="M5.3,1.3c0.2,0.3,0.3,0.7,0.5,1L5.3,1.3z"></path><path fill="#184C91" d="M71.6,0h-67c0.1,0,0.1,0,0.1,0.1c0.2,0.4,0.4,0.8,0.6,1.2l0.5,1C6.2,2.8,6,3.2,6.4,3.7c0.5,1,1.5,2.2,2.1,3.4
|
||||
c0.3,0.2,0.6,0.5,0.9,0.7c0.4,0.5,0.8,0.9,1.2,1.4c0.5,0.3,0.9,0.7,1.4,1.1c0.2,0.4,0.3,0.4,0.5,0.8c0.2,0,0.5-0.1,0.8,0.2
|
||||
c0.3,0.3,0.6,0.9,0.8,1.1c0.5,0.2,0.7,0.6,1.2,0.8c0.3,0.5,1,0.7,1.3,1.2c0.7,0.5,1.3,1.2,1.9,1.7c0.5,0.8,0.7,1.6,1.6,1.5
|
||||
c1.1,0.3,1.4,1,1.6,1.6c0,0.3,0,0.7,0,1.1c0.6,0.4,1.1,0.6,1.6,1c1.2,0.1,1.9,1,2,2.3c0.4,0.3,0.8,0.7,1.2,1
|
||||
c0.6-0.1,1.2-0.1,1.8,0.7c0.4-0.2,0.9-0.3,1.5-0.1c0.4,0.2,0.8,0.4,1.2,0.6c0.3-0.1,0.6-0.1,0.8-0.1c0.2-1,0-1.7-0.2-2.3
|
||||
c-0.2-0.9,0-1.5,0.6-2c0.1-0.9,0.5-2.2,0.2-2.3c0.1,0,0.2,0,0.2,0c-0.1,0.6-0.1,1.3-0.3,1.9c0.4,1.2,4.9,1.1,9.6,0.9
|
||||
c1-0.2,1.8-1,1.4-1.5c-0.9,0.3-1.9-0.3-2.9-0.8c-3.3-2.1-5.6-2.1-7.7-1.9c-0.6,0.1-2,1.4-0.5,1.4h0c-0.8,0.4-1.4,0.2-0.6-1.6
|
||||
c0.8-1.3,1.6-3.3,2.5-3.8c0.8-0.5,1.6-1.1,2.4-1.6c1.5-0.8,3.1-1.1,5.3-0.8c1.8,0,3.9,0.9,5.4,1.9c0.6,1.3,0.7,2.5,0.6,3.9
|
||||
c0.2,1.4,0.2,2.9-0.4,4.4c-0.2,0.9-0.5,1.7-0.7,2.5c0,0.4,0,0.7,0,1.1c-0.2,0.3-0.2,0.6,0.1,0.9c0.6,0.1,1.1,0.2,1.6,0.3
|
||||
c0.9-0.4,1.8-0.4,2.5,0c0.7-1.2,1.2-1.9,1.9-2.4c0.3-0.8,1.1-1.5,1.8-1.9c0.4-0.8,1.2-1.1,1.9-1.2c0.3-0.7,0.6-1.1,1.5-1.7
|
||||
c0.2-0.5,0.7-1.1,1.2-1.1c0.2-0.4,0.5-0.9,0.9-1.1c0.4-0.4,0.5-1.1,1.1-1.2c0.3-0.2,0.4-0.5,0.7-0.7c0-0.2,0.6-0.4,0.8-0.5
|
||||
c0.4-1.2,0.9-1.8,1.6-1.5c0-0.3,0.2-0.5,0.3-0.8c0.2-0.3,0.5-0.2,0.6-0.2c0.3-0.2,0.4-0.4,0.7-0.9c0-0.9,0.7-2,1.3-2.7
|
||||
c0.3-0.7,0.6-1.1,1-1.6c0.3-0.6,0.7-1.5,1-2.1c0.6-1.1,1.1-1.7,1.6-1.1c0.3,0.3,0.2,0.8-0.2,1.5c-0.2,0.9-0.6,1.8-0.7,2.7
|
||||
c1,0.1,1.4,0.5,1.4,1.4c0.1,0.1,0.2,0.2,0.2,0.3c0,0,0,0.1,0.1,0.1v0c0,0,0.1,0.1,0.1,0.1L71.6,0z"></path><path fill="#184C91" d="M71.5,12L71.5,12c-0.2,0.2-0.4,0.4-0.5,0.6c-0.3,0.3-0.5,0.6-0.7,0.9c-0.4,0.7-1.5,0.8-2.1,1.5
|
||||
c0.2,0.8-0.2,1.6-1,1.9c0.5,1.1-0.4,1.8-1.8,2.8c-0.1,0.9-1.5,2.3-2.5,3.6c-0.1,0.8-0.5,1.4-1.3,2.1c0,1.5-1.6,1.3-2.3,2.1
|
||||
c-0.2,1.6-1.6,3-3.6,4.5c-0.9,1.7-2.3,2.3-3.7,3.2c0.1,0.4-0.2,1.2-0.3,2.1c-1.2,5.3-2.9,9.4-3.4,15.6c0,0.2-0.2,0.4-0.2,0.7
|
||||
c0,0.3,0.2,0.7,0.2,1c0.1,0.5,0.2,1,0.3,1.4h23l0-44.1C71.5,11.9,71.5,12,71.5,12z"></path><path fill="#D82727" d="M26.4,34c-2.2-1.4-4-2.8-5.8-4.7c-1.2-1.2-3.4-3-3.5-4.3c-0.1-0.7-0.7-1-1.1-1.6c-1.4-1.1-1.9-2.2-3.6-4.4
|
||||
c-0.4-0.7-1-1.3-2.1-2.1c-0.6-0.7-1-1.5-1.6-2.2c-0.1-0.1-0.3-0.3-0.4-0.4c-0.2-0.1-0.4-0.6-0.6-1c-0.5-0.6-0.7-0.5-0.5-1.4
|
||||
c-0.1-0.5-0.3-1-1.1-1c-0.5,0.2-0.9-0.4-1.2-0.8C4.8,9.4,4.6,9,4.3,8.2C4.2,8.8,3.8,9.3,3.4,9.1c-0.8-0.4-0.5-1-0.8-2
|
||||
C2.3,6.4,2,6.5,2.9,5.4c0.6-0.7,0.9-1.1,1.6-1.1c0-0.6-0.3-0.9-0.3-1.4C4.2,2.3,4,1.7,3.8,1.1c0-0.5,0-1,0.3-1.1H0v56h28.8
|
||||
c0.3-1.1,0.5-2.1,0.8-3.3C30.1,46.7,28.4,39.5,26.4,34z"></path><path fill="#184C91" d="M299.9,17.8c0-0.2,0-0.3,0.1-0.5c0-0.2,0.1-0.3,0.2-0.4c0.1-0.1,0.2-0.3,0.3-0.4c0.1-0.1,0.2-0.2,0.4-0.3
|
||||
c0.1-0.1,0.3-0.1,0.4-0.2c0.2,0,0.3-0.1,0.5-0.1c0.2,0,0.3,0,0.5,0.1c0.2,0,0.3,0.1,0.4,0.2c0.1,0.1,0.3,0.2,0.4,0.3
|
||||
c0.1,0.1,0.2,0.2,0.3,0.4c0.1,0.1,0.1,0.3,0.2,0.4c0,0.2,0.1,0.3,0.1,0.5c0,0.2,0,0.3-0.1,0.5c0,0.2-0.1,0.3-0.2,0.4
|
||||
c-0.1,0.1-0.2,0.3-0.3,0.4c-0.1,0.1-0.2,0.2-0.4,0.3c-0.1,0.1-0.3,0.1-0.4,0.2s-0.3,0.1-0.5,0.1c-0.2,0-0.3,0-0.5-0.1
|
||||
c-0.2,0-0.3-0.1-0.4-0.2c-0.1-0.1-0.3-0.2-0.4-0.3c-0.1-0.1-0.2-0.2-0.3-0.4c-0.1-0.1-0.1-0.3-0.2-0.4
|
||||
C299.9,18.1,299.9,18,299.9,17.8z M300.2,17.8c0,0.2,0,0.4,0.1,0.6c0.1,0.2,0.2,0.3,0.3,0.5c0.1,0.1,0.3,0.2,0.5,0.3
|
||||
c0.2,0.1,0.4,0.1,0.6,0.1c0.2,0,0.4,0,0.6-0.1c0.2-0.1,0.3-0.2,0.5-0.3c0.1-0.1,0.2-0.3,0.3-0.5c0.1-0.2,0.1-0.4,0.1-0.6
|
||||
c0-0.1,0-0.3-0.1-0.4c0-0.1-0.1-0.2-0.2-0.4c-0.1-0.1-0.1-0.2-0.2-0.3c-0.1-0.1-0.2-0.2-0.3-0.2c-0.1-0.1-0.2-0.1-0.4-0.1
|
||||
c-0.1,0-0.3,0-0.4,0c-0.1,0-0.3,0-0.4,0.1c-0.1,0-0.2,0.1-0.3,0.2c-0.1,0.1-0.2,0.1-0.3,0.2c-0.1,0.1-0.2,0.2-0.2,0.3
|
||||
c-0.1,0.1-0.1,0.2-0.1,0.4C300.2,17.5,300.2,17.6,300.2,17.8z M301.4,18.1l0,0.8l-0.5,0l0-2.2l0.8,0c0.3,0,0.5,0,0.7,0.1
|
||||
c0.1,0.1,0.2,0.3,0.2,0.5c0,0.1,0,0.3-0.1,0.4c-0.1,0.1-0.2,0.2-0.3,0.2c0,0,0.1,0,0.1,0.1c0,0,0,0.1,0.1,0.1l0.5,0.7l-0.5,0
|
||||
c-0.1,0-0.1,0-0.2-0.1l-0.4-0.6c0,0,0,0-0.1-0.1c0,0-0.1,0-0.1,0L301.4,18.1z M301.4,17.7l0.2,0c0.1,0,0.2,0,0.2,0
|
||||
c0.1,0,0.1,0,0.1-0.1c0,0,0-0.1,0.1-0.1c0,0,0-0.1,0-0.1c0-0.1,0-0.1,0-0.1c0,0,0-0.1-0.1-0.1c0,0-0.1,0-0.1-0.1c0,0-0.1,0-0.2,0
|
||||
l-0.3,0L301.4,17.7z"></path><path fill="#184C91" d="M110.1,39l3.3-7.1h0.8l2.2,7.1h12.4l-2.2-7.2c3.3-0.4,6.9-3,8.4-6.3l3.3-7.1c1.6-3.5,0.1-6.3-3.3-6.3H110
|
||||
L97.7,39H110.1z M121.4,14.5h6.2l-6.9,15h-6.2L121.4,14.5z"></path></svg></div></span></div><p class="chakra-text css-1mud8qf">You are not logged in.</p><div role="group" class="chakra-button__group chakra-stack css-4jt4m7" data-orientation="horizontal"><button type="button" class="chakra-button css-h9kfy" aria-label="Log in" tabindex="0"><span class="chakra-button__icon css-1wh2kri"><svg viewBox="0 0 16 16" focusable="false" class="chakra-icon css-onkibi" aria-hidden="true"><path d="M11.9999 1.5H9.74993C9.33571 1.5 8.99993 1.16421 8.99993 0.75C8.99993 0.335786 9.33571 0 9.74993 0H11.9999C13.1045 0 13.9999 0.895431 13.9999 2V14C13.9999 15.1046 13.1045 16 11.9999 16H9.74993C9.33571 16 8.99993 15.6642 8.99993 15.25C8.99993 14.8358 9.33571 14.5 9.74993 14.5H11.9999C12.2761 14.5 12.4999 14.2761 12.4999 14V2C12.4999 1.72386 12.2761 1.5 11.9999 1.5Z" fill="currentColor"></path><path d="M6.9267 4.43945C7.21959 4.14656 7.69447 4.14656 7.98736 4.43945L10.6338 7.0859C11.122 7.57406 11.122 8.36551 10.6338 8.85367L7.98736 11.5001C7.69447 11.793 7.21959 11.793 6.9267 11.5001C6.63381 11.2072 6.63381 10.7323 6.9267 10.4395L8.64637 8.71978L2.75 8.71978C2.33579 8.71978 2 8.384 2 7.96978C2 7.55557 2.33579 7.21978 2.75 7.21978L8.64637 7.21978L6.9267 5.50011C6.63381 5.20722 6.63381 4.73235 6.9267 4.43945Z" fill="currentColor"></path></svg></span>Log in</button></div></div></div></div></div><span id="__chakra_env" hidden=""></span></div>
|
||||
|
||||
<script nonce="">
|
||||
// Jumpstart theme
|
||||
(function load() {
|
||||
const cssCommitish = '0e20cfa'
|
||||
const environment = 'members'
|
||||
var chakraTheme = localStorage.getItem("chakra-ui-color-mode");
|
||||
var cookieTheme = document.cookie.replace(/(?:(?:^|.*;\s*)theme\s*=\s*([^;]*).*$)|^.*$/, "$1");
|
||||
var theme = chakraTheme || cookieTheme || "light";
|
||||
|
||||
// Browser bar style
|
||||
var meta = document.createElement("meta");
|
||||
meta.setAttribute("name", "theme-color");
|
||||
meta.setAttribute("content", theme === "dark" ? "#05050F" : "#CDCDCF");
|
||||
document.getElementsByTagName("head")[0].appendChild(meta);
|
||||
|
||||
if (theme === "dark") {
|
||||
document.documentElement.setAttribute("data-theme", "dark");
|
||||
document.querySelector(`link[href="https://${environment}-assets.iracing.com/public/shared-css/${cssCommitish}/styles/light.min.css"]`).href = `https://${environment}-assets.iracing.com/public/shared-css/${cssCommitish}/styles/dark.min.css`;
|
||||
localStorage.setItem("chakra-ui-color-mode", "dark");
|
||||
} else {
|
||||
document.documentElement.setAttribute("data-theme", "light");
|
||||
localStorage.setItem("chakra-ui-color-mode", "light");
|
||||
}
|
||||
})();
|
||||
|
||||
// Tag Manager
|
||||
(function() {
|
||||
const tagFrame = document.createElement("iframe");
|
||||
tagFrame.style.display = "none";
|
||||
tagFrame.style.visibility = "hidden";
|
||||
document.body.appendChild(tagFrame);
|
||||
tagFrame.setAttribute("src", "https://www.googletagmanager.com/ns.html?id=GTM-TQBRJCCM");
|
||||
})();
|
||||
|
||||
</script><iframe src="https://www.googletagmanager.com/ns.html?id=GTM-TQBRJCCM" style="display: none; visibility: hidden;"></iframe>
|
||||
|
||||
<script src="/web/js/features.06672f47edf44cc1.min.js" nonce=""></script>
|
||||
|
||||
<script src="/web/js/modals.ae8419431eceaffb.min.js" nonce=""></script>
|
||||
|
||||
<script src="/web/js/partials.5bbc922034ab9879.min.js" nonce=""></script>
|
||||
|
||||
<script src="/web/js/content.444652900591a9f4.min.js" nonce=""></script>
|
||||
|
||||
<script src="/web/js/pages.9bce6de2e3069f68.min.js" nonce=""></script>
|
||||
|
||||
<script src="/web/js/cards.9166c4c695a3fd3c.min.js" nonce=""></script>
|
||||
|
||||
<script src="/web/js/echarts.0fde742e8e70602d.min.js" nonce=""></script>
|
||||
|
||||
<script src="/web/js/moment.4ea910c71253b76a.min.js" nonce=""></script>
|
||||
|
||||
<script src="/web/js/interface.b0697a5bfaa16f8e.min.js" nonce=""></script>
|
||||
|
||||
<script src="/web/js/vendor.893c2f1bd68de2a3.min.js" nonce=""></script>
|
||||
|
||||
<script src="/web/js/main.bccb09d6090f2763.min.js" nonce=""></script><div class="chakra-portal"><div role="region" aria-live="polite" aria-label="Notifications-top" id="chakra-toast-manager-top" style="position: fixed; z-index: var(--toast-z-index, 5500); pointer-events: none; display: flex; flex-direction: column; margin: 0px auto; top: env(safe-area-inset-top, 0px); right: env(safe-area-inset-right, 0px); left: env(safe-area-inset-left, 0px);"></div><div role="region" aria-live="polite" aria-label="Notifications-top-left" id="chakra-toast-manager-top-left" style="position: fixed; z-index: var(--toast-z-index, 5500); pointer-events: none; display: flex; flex-direction: column; top: env(safe-area-inset-top, 0px); left: env(safe-area-inset-left, 0px);"></div><div role="region" aria-live="polite" aria-label="Notifications-top-right" id="chakra-toast-manager-top-right" style="position: fixed; z-index: var(--toast-z-index, 5500); pointer-events: none; display: flex; flex-direction: column; top: env(safe-area-inset-top, 0px); right: env(safe-area-inset-right, 0px);"></div><div role="region" aria-live="polite" aria-label="Notifications-bottom-left" id="chakra-toast-manager-bottom-left" style="position: fixed; z-index: var(--toast-z-index, 5500); pointer-events: none; display: flex; flex-direction: column; bottom: env(safe-area-inset-bottom, 0px); left: env(safe-area-inset-left, 0px);"></div><div role="region" aria-live="polite" aria-label="Notifications-bottom" id="chakra-toast-manager-bottom" style="position: fixed; z-index: var(--toast-z-index, 5500); pointer-events: none; display: flex; flex-direction: column; margin: 0px auto; bottom: env(safe-area-inset-bottom, 0px); right: env(safe-area-inset-right, 0px); left: env(safe-area-inset-left, 0px);"></div><div role="region" aria-live="polite" aria-label="Notifications-bottom-right" id="chakra-toast-manager-bottom-right" style="position: fixed; z-index: var(--toast-z-index, 5500); pointer-events: none; display: flex; flex-direction: column; bottom: env(safe-area-inset-bottom, 0px); right: env(safe-area-inset-right, 0px);"></div></div><div class="chakra-portal"><div role="region" aria-live="polite" aria-label="Notifications-top" id="chakra-toast-manager-top" style="position: fixed; z-index: var(--toast-z-index, 5500); pointer-events: none; display: flex; flex-direction: column; margin: 0px auto; top: env(safe-area-inset-top, 0px); right: env(safe-area-inset-right, 0px); left: env(safe-area-inset-left, 0px);"></div><div role="region" aria-live="polite" aria-label="Notifications-top-left" id="chakra-toast-manager-top-left" style="position: fixed; z-index: var(--toast-z-index, 5500); pointer-events: none; display: flex; flex-direction: column; top: env(safe-area-inset-top, 0px); left: env(safe-area-inset-left, 0px);"></div><div role="region" aria-live="polite" aria-label="Notifications-top-right" id="chakra-toast-manager-top-right" style="position: fixed; z-index: var(--toast-z-index, 5500); pointer-events: none; display: flex; flex-direction: column; top: env(safe-area-inset-top, 0px); right: env(safe-area-inset-right, 0px);"></div><div role="region" aria-live="polite" aria-label="Notifications-bottom-left" id="chakra-toast-manager-bottom-left" style="position: fixed; z-index: var(--toast-z-index, 5500); pointer-events: none; display: flex; flex-direction: column; bottom: env(safe-area-inset-bottom, 0px); left: env(safe-area-inset-left, 0px);"></div><div role="region" aria-live="polite" aria-label="Notifications-bottom" id="chakra-toast-manager-bottom" style="position: fixed; z-index: var(--toast-z-index, 5500); pointer-events: none; display: flex; flex-direction: column; margin: 0px auto; bottom: env(safe-area-inset-bottom, 0px); right: env(safe-area-inset-right, 0px); left: env(safe-area-inset-left, 0px);"></div><div role="region" aria-live="polite" aria-label="Notifications-bottom-right" id="chakra-toast-manager-bottom-right" style="position: fixed; z-index: var(--toast-z-index, 5500); pointer-events: none; display: flex; flex-direction: column; bottom: env(safe-area-inset-bottom, 0px); right: env(safe-area-inset-right, 0px);"></div></div>
|
||||
|
||||
|
||||
<div id="gridpilot-overlay">
|
||||
<div class="gridpilot-card">
|
||||
<div class="gridpilot-header">
|
||||
<span class="gridpilot-logo">⚠️</span>
|
||||
<span class="gridpilot-title">GridPilot</span>
|
||||
<div class="gridpilot-header-buttons">
|
||||
<button class="gridpilot-btn gridpilot-close-btn" id="gridpilot-close-btn" onclick="(function() {
|
||||
window.__gridpilot_close_requested = true;
|
||||
})()">Stop</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="gridpilot-body">
|
||||
<div class="gridpilot-status">
|
||||
<div class="gridpilot-spinner" style="display: none;"></div>
|
||||
<span class="gridpilot-action-text" id="gridpilot-action">❌ Failed at step 2</span>
|
||||
</div>
|
||||
<div class="gridpilot-progress-container">
|
||||
<div class="gridpilot-progress-bar">
|
||||
<div class="gridpilot-progress-fill" id="gridpilot-progress" style="width: 12%;"></div>
|
||||
</div>
|
||||
<div class="gridpilot-step-info">
|
||||
<span class="gridpilot-step-text" id="gridpilot-step-text">🏁 Creating your race session...</span>
|
||||
<span class="gridpilot-step-count" id="gridpilot-step-count">Stopped</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="gridpilot-personality" id="gridpilot-personality">🔧 Check the error and try again.</div>
|
||||
</div>
|
||||
<div class="gridpilot-footer">
|
||||
<div class="gridpilot-footer-dot"></div>
|
||||
<span class="gridpilot-footer-text">Automating your session setup</span>
|
||||
</div>
|
||||
</div>
|
||||
</div></body></html>
|
||||
|
After Width: | Height: | Size: 224 KiB |
@@ -23,6 +23,8 @@ export default defineConfig({
|
||||
'@nut-tree-fork/shared',
|
||||
'bufferutil',
|
||||
'utf-8-validate',
|
||||
'playwright',
|
||||
'playwright-core',
|
||||
],
|
||||
output: {
|
||||
entryFileNames: 'main.cjs',
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { app } from 'electron';
|
||||
import * as path from 'path';
|
||||
import { InMemorySessionRepository } from '@/packages/infrastructure/repositories/InMemorySessionRepository';
|
||||
import { MockBrowserAutomationAdapter } from '@/packages/infrastructure/adapters/automation/MockBrowserAutomationAdapter';
|
||||
import { NutJsAutomationAdapter } from '@/packages/infrastructure/adapters/automation/NutJsAutomationAdapter';
|
||||
import { PlaywrightAutomationAdapter, AutomationAdapterMode } from '@/packages/infrastructure/adapters/automation/PlaywrightAutomationAdapter';
|
||||
import { MockAutomationEngineAdapter } from '@/packages/infrastructure/adapters/automation/MockAutomationEngineAdapter';
|
||||
import { PermissionService } from '@/packages/infrastructure/adapters/automation/PermissionService';
|
||||
import { StartAutomationSessionUseCase } from '@/packages/application/use-cases/StartAutomationSessionUseCase';
|
||||
import { CheckAuthenticationUseCase } from '@/packages/application/use-cases/CheckAuthenticationUseCase';
|
||||
import { InitiateLoginUseCase } from '@/packages/application/use-cases/InitiateLoginUseCase';
|
||||
import { ClearSessionUseCase } from '@/packages/application/use-cases/ClearSessionUseCase';
|
||||
import { loadAutomationConfig, getAutomationMode, AutomationMode } from '@/packages/infrastructure/config';
|
||||
import { PinoLogAdapter } from '@/packages/infrastructure/adapters/logging/PinoLogAdapter';
|
||||
import { NoOpLogAdapter } from '@/packages/infrastructure/adapters/logging/NoOpLogAdapter';
|
||||
@@ -11,6 +15,7 @@ import { loadLoggingConfig } from '@/packages/infrastructure/config/LoggingConfi
|
||||
import type { ISessionRepository } from '@/packages/application/ports/ISessionRepository';
|
||||
import type { IScreenAutomation } from '@/packages/application/ports/IScreenAutomation';
|
||||
import type { IAutomationEngine } from '@/packages/application/ports/IAutomationEngine';
|
||||
import type { IAuthenticationService } from '@/packages/application/ports/IAuthenticationService';
|
||||
import type { ILogger } from '@/packages/application/ports/ILogger';
|
||||
|
||||
export interface BrowserConnectionResult {
|
||||
@@ -18,6 +23,39 @@ export interface BrowserConnectionResult {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the path to store persistent browser session data.
|
||||
* Uses Electron's userData directory for secure, per-user storage.
|
||||
*
|
||||
* @returns Absolute path to the iracing session directory
|
||||
*/
|
||||
function resolveSessionDataPath(): string {
|
||||
const userDataPath = app.getPath('userData');
|
||||
return path.join(userDataPath, 'iracing-session');
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the absolute path to the template directory.
|
||||
* Handles both development and production (packaged) Electron environments.
|
||||
*
|
||||
* @returns Absolute path to the iracing templates directory
|
||||
*/
|
||||
function resolveTemplatePath(): string {
|
||||
// In packaged app, app.getAppPath() returns the path to the app.asar or unpacked directory
|
||||
// In development, it returns the path to the app directory (apps/companion)
|
||||
const appPath = app.getAppPath();
|
||||
|
||||
if (app.isPackaged) {
|
||||
// Production: resources are in the app.asar or unpacked directory
|
||||
return path.join(appPath, 'resources/templates/iracing');
|
||||
}
|
||||
|
||||
// Development: navigate from apps/companion to project root
|
||||
// __dirname is apps/companion/main (or dist equivalent)
|
||||
// appPath is apps/companion
|
||||
return path.join(appPath, '../../resources/templates/iracing');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create logger based on environment configuration.
|
||||
* In test environment, returns NoOpLogAdapter for silent logging.
|
||||
@@ -32,23 +70,57 @@ function createLogger(): ILogger {
|
||||
return new PinoLogAdapter(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the adapter mode based on environment.
|
||||
* - 'production' → 'real' (uses iRacing website selectors)
|
||||
* - 'development' → 'real' (uses iRacing website selectors)
|
||||
* - 'test' → 'mock' (uses data-* attribute selectors)
|
||||
*/
|
||||
function getAdapterMode(envMode: AutomationMode): AutomationAdapterMode {
|
||||
return envMode === 'test' ? 'mock' : 'real';
|
||||
}
|
||||
|
||||
/**
|
||||
* Create screen automation adapter based on configuration mode.
|
||||
*
|
||||
* Mode mapping:
|
||||
* - 'production' → NutJsAutomationAdapter with iRacing window
|
||||
* - 'test'/'development' → MockBrowserAutomationAdapter
|
||||
* - 'production' → PlaywrightAutomationAdapter with mode='real' for iRacing website
|
||||
* - 'development' → PlaywrightAutomationAdapter with mode='real' for iRacing website
|
||||
* - 'test' → MockBrowserAutomationAdapter
|
||||
*
|
||||
* @param mode - The automation mode from configuration
|
||||
* @param logger - Logger instance for the adapter
|
||||
* @returns IScreenAutomation adapter instance
|
||||
* @returns PlaywrightAutomationAdapter instance (implements both IScreenAutomation and IAuthenticationService)
|
||||
*/
|
||||
function createBrowserAutomationAdapter(mode: AutomationMode, logger: ILogger): IScreenAutomation {
|
||||
function createBrowserAutomationAdapter(mode: AutomationMode, logger: ILogger): PlaywrightAutomationAdapter | MockBrowserAutomationAdapter {
|
||||
const config = loadAutomationConfig();
|
||||
|
||||
// Resolve absolute template path for Electron environment
|
||||
const absoluteTemplatePath = resolveTemplatePath();
|
||||
const sessionDataPath = resolveSessionDataPath();
|
||||
|
||||
logger.debug('Resolved paths', {
|
||||
absoluteTemplatePath,
|
||||
sessionDataPath,
|
||||
appPath: app.getAppPath(),
|
||||
isPackaged: app.isPackaged,
|
||||
cwd: process.cwd()
|
||||
});
|
||||
|
||||
const adapterMode = getAdapterMode(mode);
|
||||
logger.info('Creating browser automation adapter', { envMode: mode, adapterMode });
|
||||
|
||||
switch (mode) {
|
||||
case 'production':
|
||||
return new NutJsAutomationAdapter(config.nutJs, logger.child({ adapter: 'NutJs' }));
|
||||
case 'development':
|
||||
return new PlaywrightAutomationAdapter(
|
||||
{
|
||||
headless: mode === 'production',
|
||||
mode: adapterMode,
|
||||
userDataDir: sessionDataPath,
|
||||
},
|
||||
logger.child({ adapter: 'Playwright', mode: adapterMode })
|
||||
);
|
||||
|
||||
case 'test':
|
||||
default:
|
||||
@@ -61,11 +133,13 @@ export class DIContainer {
|
||||
|
||||
private logger: ILogger;
|
||||
private sessionRepository: ISessionRepository;
|
||||
private browserAutomation: IScreenAutomation;
|
||||
private browserAutomation: PlaywrightAutomationAdapter | MockBrowserAutomationAdapter;
|
||||
private automationEngine: IAutomationEngine;
|
||||
private startAutomationUseCase: StartAutomationSessionUseCase;
|
||||
private checkAuthenticationUseCase: CheckAuthenticationUseCase | null = null;
|
||||
private initiateLoginUseCase: InitiateLoginUseCase | null = null;
|
||||
private clearSessionUseCase: ClearSessionUseCase | null = null;
|
||||
private automationMode: AutomationMode;
|
||||
private permissionService: PermissionService;
|
||||
|
||||
private constructor() {
|
||||
// Initialize logger first - it's needed by other components
|
||||
@@ -90,9 +164,14 @@ export class DIContainer {
|
||||
this.browserAutomation,
|
||||
this.sessionRepository
|
||||
);
|
||||
this.permissionService = new PermissionService(
|
||||
this.logger.child({ service: 'PermissionService' })
|
||||
);
|
||||
|
||||
// Create authentication use cases only for real mode (PlaywrightAutomationAdapter)
|
||||
if (this.browserAutomation instanceof PlaywrightAutomationAdapter) {
|
||||
const authService = this.browserAutomation as IAuthenticationService;
|
||||
this.checkAuthenticationUseCase = new CheckAuthenticationUseCase(authService);
|
||||
this.initiateLoginUseCase = new InitiateLoginUseCase(authService);
|
||||
this.clearSessionUseCase = new ClearSessionUseCase(authService);
|
||||
}
|
||||
|
||||
this.logger.info('DIContainer initialized', {
|
||||
automationMode: config.mode,
|
||||
@@ -103,9 +182,12 @@ export class DIContainer {
|
||||
|
||||
private getBrowserAutomationType(mode: AutomationMode): string {
|
||||
switch (mode) {
|
||||
case 'production': return 'NutJsAutomationAdapter';
|
||||
case 'production':
|
||||
case 'development':
|
||||
return 'PlaywrightAutomationAdapter';
|
||||
case 'test':
|
||||
default: return 'MockBrowserAutomationAdapter';
|
||||
default:
|
||||
return 'MockBrowserAutomationAdapter';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,31 +222,46 @@ export class DIContainer {
|
||||
return this.logger;
|
||||
}
|
||||
|
||||
public getPermissionService(): PermissionService {
|
||||
return this.permissionService;
|
||||
public getCheckAuthenticationUseCase(): CheckAuthenticationUseCase | null {
|
||||
return this.checkAuthenticationUseCase;
|
||||
}
|
||||
|
||||
public getInitiateLoginUseCase(): InitiateLoginUseCase | null {
|
||||
return this.initiateLoginUseCase;
|
||||
}
|
||||
|
||||
public getClearSessionUseCase(): ClearSessionUseCase | null {
|
||||
return this.clearSessionUseCase;
|
||||
}
|
||||
|
||||
public getAuthenticationService(): IAuthenticationService | null {
|
||||
if (this.browserAutomation instanceof PlaywrightAutomationAdapter) {
|
||||
return this.browserAutomation as IAuthenticationService;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize automation connection based on mode.
|
||||
* In production mode, connects to iRacing window via nut.js.
|
||||
* In test/development mode, returns success immediately (no connection needed).
|
||||
* In production/development mode, connects via Playwright browser automation.
|
||||
* In test mode, returns success immediately (no connection needed).
|
||||
*/
|
||||
public async initializeBrowserConnection(): Promise<BrowserConnectionResult> {
|
||||
this.logger.info('Initializing automation connection', { mode: this.automationMode });
|
||||
|
||||
if (this.automationMode === 'production') {
|
||||
if (this.automationMode === 'production' || this.automationMode === 'development') {
|
||||
try {
|
||||
const nutJsAdapter = this.browserAutomation as NutJsAutomationAdapter;
|
||||
const result = await nutJsAdapter.connect();
|
||||
const playwrightAdapter = this.browserAutomation as PlaywrightAutomationAdapter;
|
||||
const result = await playwrightAdapter.connect();
|
||||
if (!result.success) {
|
||||
this.logger.error('Automation connection failed', new Error(result.error || 'Unknown error'), { mode: 'production' });
|
||||
this.logger.error('Automation connection failed', new Error(result.error || 'Unknown error'), { mode: this.automationMode });
|
||||
return { success: false, error: result.error };
|
||||
}
|
||||
this.logger.info('Automation connection established', { mode: 'production', adapter: 'NutJs' });
|
||||
this.logger.info('Automation connection established', { mode: this.automationMode, adapter: 'Playwright' });
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : 'Failed to initialize nut.js';
|
||||
this.logger.error('Automation connection failed', error instanceof Error ? error : new Error(errorMsg), { mode: 'production' });
|
||||
const errorMsg = error instanceof Error ? error.message : 'Failed to initialize Playwright';
|
||||
this.logger.error('Automation connection failed', error instanceof Error ? error : new Error(errorMsg), { mode: this.automationMode });
|
||||
return {
|
||||
success: false,
|
||||
error: errorMsg
|
||||
@@ -172,7 +269,7 @@ export class DIContainer {
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.debug('Test/development mode - no automation connection needed');
|
||||
this.logger.debug('Test mode - no automation connection needed');
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@@ -185,7 +282,7 @@ export class DIContainer {
|
||||
|
||||
if (this.browserAutomation && 'disconnect' in this.browserAutomation) {
|
||||
try {
|
||||
await (this.browserAutomation as NutJsAutomationAdapter).disconnect();
|
||||
await (this.browserAutomation as PlaywrightAutomationAdapter).disconnect();
|
||||
this.logger.info('Automation adapter disconnected');
|
||||
} catch (error) {
|
||||
this.logger.error('Error disconnecting automation adapter', error instanceof Error ? error : new Error('Unknown error'));
|
||||
|
||||
@@ -2,8 +2,10 @@ import { app, BrowserWindow } from 'electron';
|
||||
import * as path from 'path';
|
||||
import { setupIpcHandlers } from './ipc-handlers';
|
||||
|
||||
function createWindow() {
|
||||
const mainWindow = new BrowserWindow({
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
|
||||
function createWindow(): BrowserWindow {
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1200,
|
||||
height: 800,
|
||||
webPreferences: {
|
||||
@@ -17,14 +19,15 @@ function createWindow() {
|
||||
mainWindow.loadURL('http://localhost:5173');
|
||||
mainWindow.webContents.openDevTools();
|
||||
} else {
|
||||
// Path from dist/main to dist/renderer
|
||||
mainWindow.loadFile(path.join(__dirname, '../renderer/index.html'));
|
||||
}
|
||||
|
||||
setupIpcHandlers(mainWindow);
|
||||
|
||||
return mainWindow;
|
||||
}
|
||||
|
||||
app.whenReady().then(() => {
|
||||
app.whenReady().then(async () => {
|
||||
createWindow();
|
||||
|
||||
app.on('activate', () => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { BrowserWindow, IpcMainInvokeEvent } from 'electron';
|
||||
import { DIContainer } from './di-container';
|
||||
import type { HostedSessionConfig } from '@/packages/domain/entities/HostedSessionConfig';
|
||||
import { StepId } from '@/packages/domain/value-objects/StepId';
|
||||
import { AuthenticationState } from '@/packages/domain/value-objects/AuthenticationState';
|
||||
|
||||
let progressMonitorInterval: NodeJS.Timeout | null = null;
|
||||
|
||||
@@ -11,58 +12,139 @@ export function setupIpcHandlers(mainWindow: BrowserWindow): void {
|
||||
const startAutomationUseCase = container.getStartAutomationUseCase();
|
||||
const sessionRepository = container.getSessionRepository();
|
||||
const automationEngine = container.getAutomationEngine();
|
||||
const permissionService = container.getPermissionService();
|
||||
const logger = container.getLogger();
|
||||
|
||||
// Permission handlers
|
||||
ipcMain.handle('automation:checkPermissions', async () => {
|
||||
// Authentication handlers
|
||||
ipcMain.handle('auth:check', async () => {
|
||||
try {
|
||||
const result = await permissionService.checkPermissions();
|
||||
return {
|
||||
success: true,
|
||||
granted: result.granted,
|
||||
status: result.status,
|
||||
missingPermissions: result.missingPermissions,
|
||||
};
|
||||
} catch (error) {
|
||||
const err = error instanceof Error ? error : new Error('Unknown error');
|
||||
logger.error('Permission check failed', err);
|
||||
return {
|
||||
success: false,
|
||||
error: err.message,
|
||||
granted: false,
|
||||
status: {
|
||||
accessibility: false,
|
||||
screenRecording: false,
|
||||
platform: process.platform,
|
||||
},
|
||||
missingPermissions: ['Accessibility', 'Screen Recording'],
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('automation:requestAccessibility', async () => {
|
||||
try {
|
||||
const granted = permissionService.requestAccessibilityPermission();
|
||||
return { success: true, granted };
|
||||
} catch (error) {
|
||||
const err = error instanceof Error ? error : new Error('Unknown error');
|
||||
logger.error('Accessibility permission request failed', err);
|
||||
return { success: false, granted: false, error: err.message };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('automation:openPermissionSettings', async (_event: IpcMainInvokeEvent, pane?: 'accessibility' | 'screenRecording') => {
|
||||
try {
|
||||
if (pane) {
|
||||
await permissionService.openSystemPreferences(pane);
|
||||
} else {
|
||||
await permissionService.openPermissionsSettings();
|
||||
logger.info('Checking authentication status');
|
||||
const checkAuthUseCase = container.getCheckAuthenticationUseCase();
|
||||
|
||||
if (!checkAuthUseCase) {
|
||||
logger.warn('Authentication not available in mock mode');
|
||||
return {
|
||||
success: true,
|
||||
state: AuthenticationState.AUTHENTICATED,
|
||||
message: 'Mock mode - authentication bypassed'
|
||||
};
|
||||
}
|
||||
|
||||
// NO browser connection needed - cookie check reads JSON file directly
|
||||
const result = await checkAuthUseCase.execute();
|
||||
if (result.isErr()) {
|
||||
logger.error('Auth check failed', result.unwrapErr());
|
||||
return { success: false, error: result.unwrapErr().message };
|
||||
}
|
||||
|
||||
const state = result.unwrap();
|
||||
logger.info('Authentication check complete', { state });
|
||||
return { success: true, state };
|
||||
} catch (error) {
|
||||
const err = error instanceof Error ? error : new Error('Unknown error');
|
||||
logger.error('Auth check failed', err);
|
||||
return { success: false, error: err.message };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('auth:login', async () => {
|
||||
try {
|
||||
logger.info('Starting iRacing login flow (will wait for completion)');
|
||||
|
||||
const authService = container.getAuthenticationService();
|
||||
|
||||
if (!authService) {
|
||||
// Mock mode - no actual login needed
|
||||
logger.warn('Auth service not available in mock mode');
|
||||
return { success: true, message: 'Mock mode - login bypassed' };
|
||||
}
|
||||
|
||||
// Use the Playwright browser for login (same browser used for automation)
|
||||
// This now waits for login to complete, auto-detects success, and closes browser
|
||||
const initiateLoginUseCase = container.getInitiateLoginUseCase();
|
||||
if (!initiateLoginUseCase) {
|
||||
logger.warn('Initiate login use case not available');
|
||||
return { success: false, error: 'Login not available' };
|
||||
}
|
||||
|
||||
// This call now blocks until login is complete or times out
|
||||
const result = await initiateLoginUseCase.execute();
|
||||
if (result.isErr()) {
|
||||
logger.error('Login failed or timed out', result.unwrapErr());
|
||||
return { success: false, error: result.unwrapErr().message };
|
||||
}
|
||||
|
||||
logger.info('Login completed successfully');
|
||||
return { success: true, message: 'Login completed successfully' };
|
||||
} catch (error) {
|
||||
const err = error instanceof Error ? error : new Error('Unknown error');
|
||||
logger.error('Login flow failed', err);
|
||||
return { success: false, error: err.message };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('auth:confirmLogin', async () => {
|
||||
try {
|
||||
logger.info('User confirmed login completion');
|
||||
const authService = container.getAuthenticationService();
|
||||
|
||||
if (!authService) {
|
||||
logger.warn('Auth service not available in mock mode');
|
||||
return { success: true, state: AuthenticationState.AUTHENTICATED };
|
||||
}
|
||||
|
||||
// Call confirmLoginComplete on the adapter if it exists
|
||||
if ('confirmLoginComplete' in authService) {
|
||||
const result = await (authService as any).confirmLoginComplete();
|
||||
if (result.isErr()) {
|
||||
logger.error('Confirm login failed', result.unwrapErr());
|
||||
return { success: false, error: result.unwrapErr().message };
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('Login confirmation recorded');
|
||||
return { success: true, state: AuthenticationState.AUTHENTICATED };
|
||||
} catch (error) {
|
||||
const err = error instanceof Error ? error : new Error('Unknown error');
|
||||
logger.error('Failed to confirm login', err);
|
||||
return { success: false, error: err.message };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('auth:logout', async () => {
|
||||
try {
|
||||
logger.info('Clearing session (logout)');
|
||||
const clearSessionUseCase = container.getClearSessionUseCase();
|
||||
|
||||
if (!clearSessionUseCase) {
|
||||
logger.warn('Logout not available in mock mode');
|
||||
return { success: true, message: 'Mock mode - logout bypassed' };
|
||||
}
|
||||
|
||||
const result = await clearSessionUseCase.execute();
|
||||
if (result.isErr()) {
|
||||
logger.error('Logout failed', result.unwrapErr());
|
||||
return { success: false, error: result.unwrapErr().message };
|
||||
}
|
||||
|
||||
logger.info('Session cleared successfully');
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
const err = error instanceof Error ? error : new Error('Unknown error');
|
||||
logger.error('Failed to open permission settings', err);
|
||||
logger.error('Logout failed', err);
|
||||
return { success: false, error: err.message };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('auth:getState', async () => {
|
||||
try {
|
||||
const authService = container.getAuthenticationService();
|
||||
if (!authService) {
|
||||
return { success: true, state: AuthenticationState.AUTHENTICATED };
|
||||
}
|
||||
return { success: true, state: authService.getState() };
|
||||
} catch (error) {
|
||||
const err = error instanceof Error ? error : new Error('Unknown error');
|
||||
logger.error('Failed to get auth state', err);
|
||||
return { success: false, error: err.message };
|
||||
}
|
||||
});
|
||||
@@ -76,20 +158,6 @@ export function setupIpcHandlers(mainWindow: BrowserWindow): void {
|
||||
clearInterval(progressMonitorInterval);
|
||||
progressMonitorInterval = null;
|
||||
}
|
||||
|
||||
// Check permissions before starting automation (macOS only)
|
||||
const permissionResult = await permissionService.checkPermissions();
|
||||
if (!permissionResult.granted) {
|
||||
logger.warn('Automation blocked due to missing permissions', {
|
||||
missingPermissions: permissionResult.missingPermissions,
|
||||
});
|
||||
return {
|
||||
success: false,
|
||||
error: `Missing required permissions: ${permissionResult.missingPermissions.join(', ')}. Please grant permissions in System Preferences and try again.`,
|
||||
permissionError: true,
|
||||
missingPermissions: permissionResult.missingPermissions,
|
||||
};
|
||||
}
|
||||
|
||||
// Connect to browser first (required for dev mode)
|
||||
const connectionResult = await container.initializeBrowserConnection();
|
||||
@@ -99,6 +167,27 @@ export function setupIpcHandlers(mainWindow: BrowserWindow): void {
|
||||
}
|
||||
logger.info('Browser connection established');
|
||||
|
||||
// Check authentication before starting automation (production/development mode only)
|
||||
const checkAuthUseCase = container.getCheckAuthenticationUseCase();
|
||||
if (checkAuthUseCase) {
|
||||
const authResult = await checkAuthUseCase.execute();
|
||||
if (authResult.isOk()) {
|
||||
const authState = authResult.unwrap();
|
||||
if (authState !== AuthenticationState.AUTHENTICATED) {
|
||||
logger.warn('Not authenticated - automation cannot proceed', { authState });
|
||||
return {
|
||||
success: false,
|
||||
error: 'Not authenticated. Please login first.',
|
||||
authRequired: true,
|
||||
authState,
|
||||
};
|
||||
}
|
||||
logger.info('Authentication verified');
|
||||
} else {
|
||||
logger.warn('Auth check failed, proceeding anyway', { error: authResult.unwrapErr().message });
|
||||
}
|
||||
}
|
||||
|
||||
const result = await startAutomationUseCase.execute(config);
|
||||
logger.info('Automation session created', { sessionId: result.sessionId });
|
||||
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
import { contextBridge, ipcRenderer } from 'electron';
|
||||
import type { HostedSessionConfig } from '../../../packages/domain/entities/HostedSessionConfig';
|
||||
import type { AuthenticationState } from '../../../packages/domain/value-objects/AuthenticationState';
|
||||
|
||||
export interface PermissionStatus {
|
||||
accessibility: boolean;
|
||||
screenRecording: boolean;
|
||||
platform: NodeJS.Platform;
|
||||
export interface AuthStatusEvent {
|
||||
state: AuthenticationState;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface PermissionCheckResponse {
|
||||
export interface AuthCheckResponse {
|
||||
success: boolean;
|
||||
granted: boolean;
|
||||
status: PermissionStatus;
|
||||
missingPermissions: string[];
|
||||
state?: AuthenticationState;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface AuthActionResponse {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
@@ -20,18 +25,18 @@ export interface ElectronAPI {
|
||||
success: boolean;
|
||||
sessionId?: string;
|
||||
error?: string;
|
||||
permissionError?: boolean;
|
||||
missingPermissions?: string[];
|
||||
}>;
|
||||
stopAutomation: (sessionId: string) => Promise<{ success: boolean; error?: string }>;
|
||||
getSessionStatus: (sessionId: string) => Promise<any>;
|
||||
pauseAutomation: (sessionId: string) => Promise<{ success: boolean; error?: string }>;
|
||||
resumeAutomation: (sessionId: string) => Promise<{ success: boolean; error?: string }>;
|
||||
onSessionProgress: (callback: (progress: any) => void) => void;
|
||||
// Permission APIs
|
||||
checkPermissions: () => Promise<PermissionCheckResponse>;
|
||||
requestAccessibility: () => Promise<{ success: boolean; granted: boolean; error?: string }>;
|
||||
openPermissionSettings: (pane?: 'accessibility' | 'screenRecording') => Promise<{ success: boolean; error?: string }>;
|
||||
// Authentication APIs
|
||||
onAuthStatus: (callback: (status: AuthStatusEvent) => void) => void;
|
||||
checkAuth: () => Promise<AuthCheckResponse>;
|
||||
initiateLogin: () => Promise<AuthActionResponse>;
|
||||
confirmLogin: () => Promise<AuthActionResponse>;
|
||||
logout: () => Promise<AuthActionResponse>;
|
||||
}
|
||||
|
||||
contextBridge.exposeInMainWorld('electronAPI', {
|
||||
@@ -43,9 +48,12 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
onSessionProgress: (callback: (progress: any) => void) => {
|
||||
ipcRenderer.on('session-progress', (_event, progress) => callback(progress));
|
||||
},
|
||||
// Permission APIs
|
||||
checkPermissions: () => ipcRenderer.invoke('automation:checkPermissions'),
|
||||
requestAccessibility: () => ipcRenderer.invoke('automation:requestAccessibility'),
|
||||
openPermissionSettings: (pane?: 'accessibility' | 'screenRecording') =>
|
||||
ipcRenderer.invoke('automation:openPermissionSettings', pane),
|
||||
// Authentication APIs
|
||||
onAuthStatus: (callback: (status: AuthStatusEvent) => void) => {
|
||||
ipcRenderer.on('auth:status', (_event, status) => callback(status));
|
||||
},
|
||||
checkAuth: () => ipcRenderer.invoke('auth:check'),
|
||||
initiateLogin: () => ipcRenderer.invoke('auth:login'),
|
||||
confirmLogin: () => ipcRenderer.invoke('auth:confirmLogin'),
|
||||
logout: () => ipcRenderer.invoke('auth:logout'),
|
||||
} as ElectronAPI);
|
||||
@@ -21,7 +21,6 @@
|
||||
"vite": "^5.4.21"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nut-tree-fork/nut-js": "^4.2.6",
|
||||
"puppeteer-core": "^24.31.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { SessionCreationForm } from './components/SessionCreationForm';
|
||||
import { SessionProgressMonitor } from './components/SessionProgressMonitor';
|
||||
import { LoginPrompt } from './components/LoginPrompt';
|
||||
import type { HostedSessionConfig } from '../../../packages/domain/entities/HostedSessionConfig';
|
||||
|
||||
interface SessionProgress {
|
||||
@@ -12,72 +13,97 @@ interface SessionProgress {
|
||||
errorMessage: string | null;
|
||||
}
|
||||
|
||||
interface PermissionStatus {
|
||||
accessibility: boolean;
|
||||
screenRecording: boolean;
|
||||
platform: string;
|
||||
}
|
||||
type AuthState = 'UNKNOWN' | 'AUTHENTICATED' | 'EXPIRED' | 'LOGGED_OUT' | 'CHECKING';
|
||||
type LoginStatus = 'idle' | 'waiting' | 'success' | 'error';
|
||||
|
||||
export function App() {
|
||||
const [authState, setAuthState] = useState<AuthState>('CHECKING');
|
||||
const [authError, setAuthError] = useState<string | undefined>(undefined);
|
||||
const [sessionId, setSessionId] = useState<string | null>(null);
|
||||
const [progress, setProgress] = useState<SessionProgress | null>(null);
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
const [permissionStatus, setPermissionStatus] = useState<PermissionStatus | null>(null);
|
||||
const [permissionChecking, setPermissionChecking] = useState(true);
|
||||
const [missingPermissions, setMissingPermissions] = useState<string[]>([]);
|
||||
const [loginStatus, setLoginStatus] = useState<LoginStatus>('idle');
|
||||
|
||||
const checkPermissions = useCallback(async () => {
|
||||
const handleLogin = useCallback(async () => {
|
||||
if (!window.electronAPI) return;
|
||||
|
||||
setPermissionChecking(true);
|
||||
setAuthError(undefined);
|
||||
setLoginStatus('waiting');
|
||||
|
||||
try {
|
||||
const result = await window.electronAPI.checkPermissions();
|
||||
setPermissionStatus(result.status);
|
||||
setMissingPermissions(result.missingPermissions);
|
||||
// This now waits for login to complete (auto-detects and closes browser)
|
||||
const result = await window.electronAPI.initiateLogin();
|
||||
if (result.success) {
|
||||
// Login completed successfully - browser closed automatically
|
||||
setLoginStatus('success');
|
||||
// Show success message for 2 seconds before transitioning
|
||||
setTimeout(() => {
|
||||
setAuthState('AUTHENTICATED');
|
||||
}, 2000);
|
||||
} else {
|
||||
setLoginStatus('error');
|
||||
setAuthError(result.error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to check permissions:', error);
|
||||
} finally {
|
||||
setPermissionChecking(false);
|
||||
console.error('Login failed:', error);
|
||||
setLoginStatus('error');
|
||||
setAuthError(error instanceof Error ? error.message : 'Login failed');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleRetryAuth = useCallback(async () => {
|
||||
if (!window.electronAPI) return;
|
||||
|
||||
setAuthState('CHECKING');
|
||||
setAuthError(undefined);
|
||||
setLoginStatus('idle');
|
||||
|
||||
try {
|
||||
const result = await window.electronAPI.checkAuth();
|
||||
if (result.success && result.state) {
|
||||
setAuthState(result.state as AuthState);
|
||||
} else {
|
||||
setAuthError(result.error);
|
||||
setAuthState('UNKNOWN');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Auth check failed:', error);
|
||||
setAuthError(error instanceof Error ? error.message : 'Connection failed');
|
||||
setAuthState('UNKNOWN');
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Check permissions on app start
|
||||
checkPermissions();
|
||||
if (!window.electronAPI) return;
|
||||
|
||||
if (window.electronAPI) {
|
||||
window.electronAPI.onSessionProgress((newProgress: SessionProgress) => {
|
||||
setProgress(newProgress);
|
||||
if (newProgress.state === 'COMPLETED' ||
|
||||
newProgress.state === 'FAILED' ||
|
||||
newProgress.state === 'STOPPED_AT_STEP_18') {
|
||||
setIsRunning(false);
|
||||
const checkAuth = async () => {
|
||||
try {
|
||||
const result = await window.electronAPI.checkAuth();
|
||||
if (result.success && result.state) {
|
||||
setAuthState(result.state as AuthState);
|
||||
} else {
|
||||
setAuthError(result.error || 'Failed to check authentication');
|
||||
setAuthState('UNKNOWN');
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [checkPermissions]);
|
||||
} catch (error) {
|
||||
setAuthError(error instanceof Error ? error.message : 'Failed to check authentication');
|
||||
setAuthState('UNKNOWN');
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenPermissionSettings = async (pane?: 'accessibility' | 'screenRecording') => {
|
||||
if (!window.electronAPI) return;
|
||||
await window.electronAPI.openPermissionSettings(pane);
|
||||
};
|
||||
checkAuth();
|
||||
|
||||
const handleRequestAccessibility = async () => {
|
||||
if (!window.electronAPI) return;
|
||||
await window.electronAPI.requestAccessibility();
|
||||
// Recheck permissions after request
|
||||
setTimeout(checkPermissions, 500);
|
||||
};
|
||||
window.electronAPI.onSessionProgress((newProgress: SessionProgress) => {
|
||||
setProgress(newProgress);
|
||||
if (newProgress.state === 'COMPLETED' ||
|
||||
newProgress.state === 'FAILED' ||
|
||||
newProgress.state === 'STOPPED_AT_STEP_18') {
|
||||
setIsRunning(false);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleStartAutomation = async (config: HostedSessionConfig) => {
|
||||
// Recheck permissions before starting
|
||||
await checkPermissions();
|
||||
|
||||
if (missingPermissions.length > 0) {
|
||||
alert(`Cannot start automation: Missing permissions: ${missingPermissions.join(', ')}`);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsRunning(true);
|
||||
const result = await window.electronAPI.startAutomation(config);
|
||||
|
||||
@@ -85,13 +111,7 @@ export function App() {
|
||||
setSessionId(result.sessionId);
|
||||
} else {
|
||||
setIsRunning(false);
|
||||
if (result.permissionError) {
|
||||
// Update permission status
|
||||
await checkPermissions();
|
||||
alert(`Permission Error: ${result.error}`);
|
||||
} else {
|
||||
alert(`Failed to start automation: ${result.error}`);
|
||||
}
|
||||
alert(`Failed to start automation: ${result.error}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -107,8 +127,47 @@ export function App() {
|
||||
}
|
||||
};
|
||||
|
||||
const isMacOS = permissionStatus?.platform === 'darwin';
|
||||
const hasAllPermissions = missingPermissions.length === 0;
|
||||
if (authState === 'CHECKING') {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minHeight: '100vh',
|
||||
backgroundColor: '#1a1a1a',
|
||||
}}>
|
||||
<div style={{
|
||||
width: '60px',
|
||||
height: '60px',
|
||||
border: '4px solid #333',
|
||||
borderTopColor: '#007bff',
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 1s linear infinite',
|
||||
}} />
|
||||
<style>{`
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
`}</style>
|
||||
<p style={{ color: '#aaa', marginTop: '1.5rem', fontSize: '1.1rem' }}>
|
||||
Checking authentication...
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (authState !== 'AUTHENTICATED') {
|
||||
return (
|
||||
<LoginPrompt
|
||||
authState={authState}
|
||||
errorMessage={authError}
|
||||
onLogin={handleLogin}
|
||||
onRetry={handleRetryAuth}
|
||||
loginStatus={loginStatus}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
@@ -128,126 +187,9 @@ export function App() {
|
||||
Hosted Session Automation POC
|
||||
</p>
|
||||
|
||||
{/* Permission Banner */}
|
||||
{isMacOS && !permissionChecking && !hasAllPermissions && (
|
||||
<div style={{
|
||||
marginBottom: '1.5rem',
|
||||
padding: '1rem',
|
||||
backgroundColor: '#3d2020',
|
||||
border: '1px solid #dc3545',
|
||||
borderRadius: '8px',
|
||||
}}>
|
||||
<h3 style={{ color: '#ff6b6b', margin: '0 0 0.5rem 0', fontSize: '1rem' }}>
|
||||
⚠️ Missing Permissions
|
||||
</h3>
|
||||
<p style={{ color: '#ffaaaa', margin: '0 0 1rem 0', fontSize: '0.9rem' }}>
|
||||
GridPilot requires macOS permissions to control your computer for automation.
|
||||
Please grant the following permissions:
|
||||
</p>
|
||||
<ul style={{ color: '#ffaaaa', margin: '0 0 1rem 0', paddingLeft: '1.5rem', fontSize: '0.9rem' }}>
|
||||
{!permissionStatus?.accessibility && (
|
||||
<li style={{ marginBottom: '0.5rem' }}>
|
||||
<strong>Accessibility:</strong> Required for keyboard and mouse control
|
||||
</li>
|
||||
)}
|
||||
{!permissionStatus?.screenRecording && (
|
||||
<li style={{ marginBottom: '0.5rem' }}>
|
||||
<strong>Screen Recording:</strong> Required for screen capture and window detection
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||||
{!permissionStatus?.accessibility && (
|
||||
<button
|
||||
onClick={handleRequestAccessibility}
|
||||
style={{
|
||||
padding: '0.5rem 1rem',
|
||||
backgroundColor: '#007bff',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.9rem',
|
||||
}}
|
||||
>
|
||||
Request Accessibility
|
||||
</button>
|
||||
)}
|
||||
{!permissionStatus?.accessibility && (
|
||||
<button
|
||||
onClick={() => handleOpenPermissionSettings('accessibility')}
|
||||
style={{
|
||||
padding: '0.5rem 1rem',
|
||||
backgroundColor: '#6c757d',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.9rem',
|
||||
}}
|
||||
>
|
||||
Open Accessibility Settings
|
||||
</button>
|
||||
)}
|
||||
{!permissionStatus?.screenRecording && (
|
||||
<button
|
||||
onClick={() => handleOpenPermissionSettings('screenRecording')}
|
||||
style={{
|
||||
padding: '0.5rem 1rem',
|
||||
backgroundColor: '#6c757d',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.9rem',
|
||||
}}
|
||||
>
|
||||
Open Screen Recording Settings
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={checkPermissions}
|
||||
style={{
|
||||
padding: '0.5rem 1rem',
|
||||
backgroundColor: '#28a745',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.9rem',
|
||||
}}
|
||||
>
|
||||
Recheck Permissions
|
||||
</button>
|
||||
</div>
|
||||
<p style={{ color: '#888', margin: '1rem 0 0 0', fontSize: '0.8rem' }}>
|
||||
After granting permissions in System Preferences, click "Recheck Permissions" or restart the app.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Permission Status Indicator */}
|
||||
{isMacOS && !permissionChecking && hasAllPermissions && (
|
||||
<div style={{
|
||||
marginBottom: '1.5rem',
|
||||
padding: '0.75rem 1rem',
|
||||
backgroundColor: '#1e3d1e',
|
||||
border: '1px solid #28a745',
|
||||
borderRadius: '8px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
}}>
|
||||
<span style={{ color: '#28a745', fontSize: '1.2rem' }}>✓</span>
|
||||
<span style={{ color: '#8eff8e', fontSize: '0.9rem' }}>
|
||||
All permissions granted - Ready for automation
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SessionCreationForm
|
||||
onSubmit={handleStartAutomation}
|
||||
disabled={isRunning || (isMacOS && !hasAllPermissions)}
|
||||
disabled={isRunning}
|
||||
/>
|
||||
{isRunning && (
|
||||
<button
|
||||
|
||||
294
apps/companion/renderer/components/LoginPrompt.tsx
Normal file
@@ -0,0 +1,294 @@
|
||||
import React from 'react';
|
||||
|
||||
type LoginStatus = 'idle' | 'waiting' | 'success' | 'error';
|
||||
|
||||
interface LoginPromptProps {
|
||||
authState: string;
|
||||
errorMessage?: string;
|
||||
onLogin: () => void;
|
||||
onRetry: () => void;
|
||||
loginStatus?: LoginStatus;
|
||||
}
|
||||
|
||||
export function LoginPrompt({
|
||||
authState,
|
||||
errorMessage,
|
||||
onLogin,
|
||||
onRetry,
|
||||
loginStatus = 'idle'
|
||||
}: LoginPromptProps) {
|
||||
// Show success state when login completed
|
||||
if (loginStatus === 'success') {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minHeight: '100vh',
|
||||
backgroundColor: '#1a1a1a',
|
||||
padding: '2rem',
|
||||
}}>
|
||||
<div style={{
|
||||
maxWidth: '500px',
|
||||
width: '100%',
|
||||
padding: '2rem',
|
||||
backgroundColor: '#252525',
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 4px 24px rgba(0, 0, 0, 0.3)',
|
||||
textAlign: 'center',
|
||||
}}>
|
||||
<div style={{
|
||||
width: '80px',
|
||||
height: '80px',
|
||||
margin: '0 auto 1.5rem',
|
||||
backgroundColor: '#1a472a',
|
||||
borderRadius: '50%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '2.5rem',
|
||||
color: '#4ade80',
|
||||
animation: 'scaleIn 0.3s ease-out',
|
||||
}}>
|
||||
✓
|
||||
</div>
|
||||
<style>{`
|
||||
@keyframes scaleIn {
|
||||
from { transform: scale(0); opacity: 0; }
|
||||
to { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
`}</style>
|
||||
|
||||
<h1 style={{
|
||||
color: '#4ade80',
|
||||
fontSize: '1.75rem',
|
||||
marginBottom: '0.5rem',
|
||||
fontWeight: 600,
|
||||
animation: 'fadeIn 0.4s ease-out 0.1s both',
|
||||
}}>
|
||||
Login Successful!
|
||||
</h1>
|
||||
|
||||
<p style={{
|
||||
color: '#aaa',
|
||||
fontSize: '1rem',
|
||||
marginBottom: '1rem',
|
||||
lineHeight: 1.5,
|
||||
animation: 'fadeIn 0.4s ease-out 0.2s both',
|
||||
}}>
|
||||
You're now connected to iRacing.
|
||||
</p>
|
||||
|
||||
<p style={{
|
||||
color: '#666',
|
||||
fontSize: '0.9rem',
|
||||
animation: 'fadeIn 0.4s ease-out 0.3s both',
|
||||
}}>
|
||||
Redirecting...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const getStateMessage = () => {
|
||||
switch (authState) {
|
||||
case 'EXPIRED':
|
||||
return 'Your iRacing session has expired. Please log in again to continue.';
|
||||
case 'LOGGED_OUT':
|
||||
return 'You have been logged out. Please log in to use GridPilot.';
|
||||
case 'UNKNOWN':
|
||||
return errorMessage
|
||||
? `Unable to verify authentication: ${errorMessage}`
|
||||
: 'Unable to verify your authentication status.';
|
||||
default:
|
||||
return null; // Will show explanation instead
|
||||
}
|
||||
};
|
||||
|
||||
const stateMessage = getStateMessage();
|
||||
const isWaiting = loginStatus === 'waiting';
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minHeight: '100vh',
|
||||
backgroundColor: '#1a1a1a',
|
||||
padding: '2rem',
|
||||
}}>
|
||||
<div style={{
|
||||
maxWidth: '500px',
|
||||
width: '100%',
|
||||
padding: '2rem',
|
||||
backgroundColor: '#252525',
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 4px 24px rgba(0, 0, 0, 0.3)',
|
||||
textAlign: 'center',
|
||||
}}>
|
||||
<div style={{
|
||||
width: '80px',
|
||||
height: '80px',
|
||||
margin: '0 auto 1.5rem',
|
||||
backgroundColor: '#333',
|
||||
borderRadius: '50%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '2.5rem',
|
||||
}}>
|
||||
{isWaiting ? '⏳' : '🔐'}
|
||||
</div>
|
||||
|
||||
<h1 style={{
|
||||
color: '#fff',
|
||||
fontSize: '1.75rem',
|
||||
marginBottom: '0.5rem',
|
||||
fontWeight: 600,
|
||||
}}>
|
||||
{isWaiting ? 'Waiting for Login...' : 'iRacing Login Required'}
|
||||
</h1>
|
||||
|
||||
{stateMessage ? (
|
||||
<p style={{
|
||||
color: '#aaa',
|
||||
fontSize: '1rem',
|
||||
marginBottom: '2rem',
|
||||
lineHeight: 1.5,
|
||||
}}>
|
||||
{stateMessage}
|
||||
</p>
|
||||
) : (
|
||||
<div style={{
|
||||
color: '#aaa',
|
||||
fontSize: '1rem',
|
||||
marginBottom: '2rem',
|
||||
lineHeight: 1.6,
|
||||
textAlign: 'left',
|
||||
}}>
|
||||
<p style={{ marginBottom: '1rem' }}>
|
||||
<strong style={{ color: '#fff' }}>Why do I need to log in?</strong>
|
||||
</p>
|
||||
<p style={{ marginBottom: '0.75rem' }}>
|
||||
GridPilot needs to access your iRacing account to create and manage hosted sessions on your behalf. This requires authentication with iRacing's website.
|
||||
</p>
|
||||
<ul style={{
|
||||
margin: '0.75rem 0',
|
||||
paddingLeft: '1.25rem',
|
||||
color: '#888',
|
||||
}}>
|
||||
<li style={{ marginBottom: '0.5rem' }}>✓ Your credentials are entered directly on iRacing.com</li>
|
||||
<li style={{ marginBottom: '0.5rem' }}>✓ GridPilot never sees or stores your password</li>
|
||||
<li>✓ Session cookies are stored locally for convenience</li>
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isWaiting ? (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '1rem',
|
||||
}}>
|
||||
<div style={{
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
border: '3px solid #333',
|
||||
borderTopColor: '#007bff',
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 1s linear infinite',
|
||||
}} />
|
||||
<style>{`
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
`}</style>
|
||||
<p style={{
|
||||
color: '#aaa',
|
||||
fontSize: '0.95rem',
|
||||
}}>
|
||||
A browser window has opened. Please log in to iRacing.
|
||||
</p>
|
||||
<p style={{
|
||||
color: '#666',
|
||||
fontSize: '0.85rem',
|
||||
}}>
|
||||
This window will update automatically when login is detected.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.75rem',
|
||||
}}>
|
||||
<button
|
||||
onClick={onLogin}
|
||||
style={{
|
||||
padding: '1rem 2rem',
|
||||
backgroundColor: '#007bff',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '1.1rem',
|
||||
fontWeight: 600,
|
||||
transition: 'background-color 0.2s',
|
||||
}}
|
||||
onMouseOver={(e) => e.currentTarget.style.backgroundColor = '#0056b3'}
|
||||
onMouseOut={(e) => e.currentTarget.style.backgroundColor = '#007bff'}
|
||||
>
|
||||
Log in to iRacing
|
||||
</button>
|
||||
|
||||
{(authState === 'UNKNOWN' || errorMessage) && (
|
||||
<button
|
||||
onClick={onRetry}
|
||||
style={{
|
||||
padding: '0.75rem 1.5rem',
|
||||
backgroundColor: 'transparent',
|
||||
color: '#aaa',
|
||||
border: '1px solid #555',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.95rem',
|
||||
transition: 'all 0.2s',
|
||||
}}
|
||||
onMouseOver={(e) => {
|
||||
e.currentTarget.style.borderColor = '#777';
|
||||
e.currentTarget.style.color = '#fff';
|
||||
}}
|
||||
onMouseOut={(e) => {
|
||||
e.currentTarget.style.borderColor = '#555';
|
||||
e.currentTarget.style.color = '#aaa';
|
||||
}}
|
||||
>
|
||||
Retry Connection
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isWaiting && (
|
||||
<p style={{
|
||||
color: '#666',
|
||||
fontSize: '0.85rem',
|
||||
marginTop: '2rem',
|
||||
lineHeight: 1.4,
|
||||
}}>
|
||||
A browser window will open for you to log in securely to iRacing.
|
||||
The window will close automatically once login is complete.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<!DOCTYPE html><html lang="en"><head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>iRacing - Server Details</title>
|
||||
<link rel="stylesheet" href="common.css">
|
||||
</head>
|
||||
<body data-step="4">
|
||||
|
||||
<header class="header">
|
||||
<div class="step-indicator" data-indicator="server-details">
|
||||
<span>Step</span>
|
||||
<span class="current">4</span>
|
||||
<span>of 18</span>
|
||||
<span>—</span>
|
||||
<span>Server Details</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="main">
|
||||
<h1 class="page-title">Server Details</h1>
|
||||
|
||||
<form id="server-details-form">
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="region">Server Region</label>
|
||||
<select id="region" class="form-select" data-dropdown="region">
|
||||
<option value="us-east">US East</option>
|
||||
<option value="us-west">US West</option>
|
||||
<option value="eu-central">EU Central</option>
|
||||
<option value="eu-west">EU West</option>
|
||||
<option value="asia">Asia</option>
|
||||
<option value="oceania">Oceania</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="toggle-group">
|
||||
<input type="checkbox" id="startNow" class="toggle-input" data-toggle="startNow" style="">
|
||||
<label class="toggle-label" for="startNow">Start session immediately</label>
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
|
||||
<footer class="footer">
|
||||
<button type="button" class="btn btn-secondary" data-action="back" onclick="window.location.href='step-03-create-race.html'">
|
||||
Back
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary" data-action="next" onclick="window.location.href='step-05-server-details.html'">
|
||||
Next
|
||||
</button>
|
||||
</footer>
|
||||
|
||||
|
||||
</body></html>
|
||||
@@ -0,0 +1,52 @@
|
||||
<!DOCTYPE html><html lang="en"><head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>iRacing - Server Details</title>
|
||||
<link rel="stylesheet" href="common.css">
|
||||
</head>
|
||||
<body data-step="4">
|
||||
|
||||
<header class="header">
|
||||
<div class="step-indicator" data-indicator="server-details">
|
||||
<span>Step</span>
|
||||
<span class="current">4</span>
|
||||
<span>of 18</span>
|
||||
<span>—</span>
|
||||
<span>Server Details</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="main">
|
||||
<h1 class="page-title">Server Details</h1>
|
||||
|
||||
<form id="server-details-form">
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="region">Server Region</label>
|
||||
<select id="region" class="form-select" data-dropdown="region">
|
||||
<option value="us-east">US East</option>
|
||||
<option value="us-west">US West</option>
|
||||
<option value="eu-central">EU Central</option>
|
||||
<option value="eu-west">EU West</option>
|
||||
<option value="asia">Asia</option>
|
||||
<option value="oceania">Oceania</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="toggle-group">
|
||||
<input type="checkbox" id="startNow" class="toggle-input" data-toggle="startNow" style="">
|
||||
<label class="toggle-label" for="startNow">Start session immediately</label>
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
|
||||
<footer class="footer">
|
||||
<button type="button" class="btn btn-secondary" data-action="back" onclick="window.location.href='step-03-create-race.html'">
|
||||
Back
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary" data-action="next" onclick="window.location.href='step-05-server-details.html'">
|
||||
Next
|
||||
</button>
|
||||
</footer>
|
||||
|
||||
|
||||
</body></html>
|
||||
@@ -0,0 +1,52 @@
|
||||
<!DOCTYPE html><html lang="en"><head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>iRacing - Server Details</title>
|
||||
<link rel="stylesheet" href="common.css">
|
||||
</head>
|
||||
<body data-step="4">
|
||||
|
||||
<header class="header">
|
||||
<div class="step-indicator" data-indicator="server-details">
|
||||
<span>Step</span>
|
||||
<span class="current">4</span>
|
||||
<span>of 18</span>
|
||||
<span>—</span>
|
||||
<span>Server Details</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="main">
|
||||
<h1 class="page-title">Server Details</h1>
|
||||
|
||||
<form id="server-details-form">
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="region">Server Region</label>
|
||||
<select id="region" class="form-select" data-dropdown="region">
|
||||
<option value="us-east">US East</option>
|
||||
<option value="us-west">US West</option>
|
||||
<option value="eu-central">EU Central</option>
|
||||
<option value="eu-west">EU West</option>
|
||||
<option value="asia">Asia</option>
|
||||
<option value="oceania">Oceania</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="toggle-group">
|
||||
<input type="checkbox" id="startNow" class="toggle-input" data-toggle="startNow" style="">
|
||||
<label class="toggle-label" for="startNow">Start session immediately</label>
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
|
||||
<footer class="footer">
|
||||
<button type="button" class="btn btn-secondary" data-action="back" onclick="window.location.href='step-03-create-race.html'">
|
||||
Back
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary" data-action="next" onclick="window.location.href='step-05-server-details.html'">
|
||||
Next
|
||||
</button>
|
||||
</footer>
|
||||
|
||||
|
||||
</body></html>
|
||||
@@ -0,0 +1,52 @@
|
||||
<!DOCTYPE html><html lang="en"><head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>iRacing - Server Details</title>
|
||||
<link rel="stylesheet" href="common.css">
|
||||
</head>
|
||||
<body data-step="4">
|
||||
|
||||
<header class="header">
|
||||
<div class="step-indicator" data-indicator="server-details">
|
||||
<span>Step</span>
|
||||
<span class="current">4</span>
|
||||
<span>of 18</span>
|
||||
<span>—</span>
|
||||
<span>Server Details</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="main">
|
||||
<h1 class="page-title">Server Details</h1>
|
||||
|
||||
<form id="server-details-form">
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="region">Server Region</label>
|
||||
<select id="region" class="form-select" data-dropdown="region">
|
||||
<option value="us-east">US East</option>
|
||||
<option value="us-west">US West</option>
|
||||
<option value="eu-central">EU Central</option>
|
||||
<option value="eu-west">EU West</option>
|
||||
<option value="asia">Asia</option>
|
||||
<option value="oceania">Oceania</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="toggle-group">
|
||||
<input type="checkbox" id="startNow" class="toggle-input" data-toggle="startNow" style="">
|
||||
<label class="toggle-label" for="startNow">Start session immediately</label>
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
|
||||
<footer class="footer">
|
||||
<button type="button" class="btn btn-secondary" data-action="back" onclick="window.location.href='step-03-create-race.html'">
|
||||
Back
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary" data-action="next" onclick="window.location.href='step-05-server-details.html'">
|
||||
Next
|
||||
</button>
|
||||
</footer>
|
||||
|
||||
|
||||
</body></html>
|
||||
@@ -0,0 +1,52 @@
|
||||
<!DOCTYPE html><html lang="en"><head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>iRacing - Server Details</title>
|
||||
<link rel="stylesheet" href="common.css">
|
||||
</head>
|
||||
<body data-step="4">
|
||||
|
||||
<header class="header">
|
||||
<div class="step-indicator" data-indicator="server-details">
|
||||
<span>Step</span>
|
||||
<span class="current">4</span>
|
||||
<span>of 18</span>
|
||||
<span>—</span>
|
||||
<span>Server Details</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="main">
|
||||
<h1 class="page-title">Server Details</h1>
|
||||
|
||||
<form id="server-details-form">
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="region">Server Region</label>
|
||||
<select id="region" class="form-select" data-dropdown="region">
|
||||
<option value="us-east">US East</option>
|
||||
<option value="us-west">US West</option>
|
||||
<option value="eu-central">EU Central</option>
|
||||
<option value="eu-west">EU West</option>
|
||||
<option value="asia">Asia</option>
|
||||
<option value="oceania">Oceania</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="toggle-group">
|
||||
<input type="checkbox" id="startNow" class="toggle-input" data-toggle="startNow" style="">
|
||||
<label class="toggle-label" for="startNow">Start session immediately</label>
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
|
||||
<footer class="footer">
|
||||
<button type="button" class="btn btn-secondary" data-action="back" onclick="window.location.href='step-03-create-race.html'">
|
||||
Back
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary" data-action="next" onclick="window.location.href='step-05-server-details.html'">
|
||||
Next
|
||||
</button>
|
||||
</footer>
|
||||
|
||||
|
||||
</body></html>
|
||||
@@ -0,0 +1,52 @@
|
||||
<!DOCTYPE html><html lang="en"><head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>iRacing - Server Details</title>
|
||||
<link rel="stylesheet" href="common.css">
|
||||
</head>
|
||||
<body data-step="4">
|
||||
|
||||
<header class="header">
|
||||
<div class="step-indicator" data-indicator="server-details">
|
||||
<span>Step</span>
|
||||
<span class="current">4</span>
|
||||
<span>of 18</span>
|
||||
<span>—</span>
|
||||
<span>Server Details</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="main">
|
||||
<h1 class="page-title">Server Details</h1>
|
||||
|
||||
<form id="server-details-form">
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="region">Server Region</label>
|
||||
<select id="region" class="form-select" data-dropdown="region">
|
||||
<option value="us-east">US East</option>
|
||||
<option value="us-west">US West</option>
|
||||
<option value="eu-central">EU Central</option>
|
||||
<option value="eu-west">EU West</option>
|
||||
<option value="asia">Asia</option>
|
||||
<option value="oceania">Oceania</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="toggle-group">
|
||||
<input type="checkbox" id="startNow" class="toggle-input" data-toggle="startNow" style="">
|
||||
<label class="toggle-label" for="startNow">Start session immediately</label>
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
|
||||
<footer class="footer">
|
||||
<button type="button" class="btn btn-secondary" data-action="back" onclick="window.location.href='step-03-create-race.html'">
|
||||
Back
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary" data-action="next" onclick="window.location.href='step-05-server-details.html'">
|
||||
Next
|
||||
</button>
|
||||
</footer>
|
||||
|
||||
|
||||
</body></html>
|
||||
@@ -0,0 +1,52 @@
|
||||
<!DOCTYPE html><html lang="en"><head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>iRacing - Server Details</title>
|
||||
<link rel="stylesheet" href="common.css">
|
||||
</head>
|
||||
<body data-step="4">
|
||||
|
||||
<header class="header">
|
||||
<div class="step-indicator" data-indicator="server-details">
|
||||
<span>Step</span>
|
||||
<span class="current">4</span>
|
||||
<span>of 18</span>
|
||||
<span>—</span>
|
||||
<span>Server Details</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="main">
|
||||
<h1 class="page-title">Server Details</h1>
|
||||
|
||||
<form id="server-details-form">
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="region">Server Region</label>
|
||||
<select id="region" class="form-select" data-dropdown="region">
|
||||
<option value="us-east">US East</option>
|
||||
<option value="us-west">US West</option>
|
||||
<option value="eu-central">EU Central</option>
|
||||
<option value="eu-west">EU West</option>
|
||||
<option value="asia">Asia</option>
|
||||
<option value="oceania">Oceania</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="toggle-group">
|
||||
<input type="checkbox" id="startNow" class="toggle-input" data-toggle="startNow" style="">
|
||||
<label class="toggle-label" for="startNow">Start session immediately</label>
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
|
||||
<footer class="footer">
|
||||
<button type="button" class="btn btn-secondary" data-action="back" onclick="window.location.href='step-03-create-race.html'">
|
||||
Back
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary" data-action="next" onclick="window.location.href='step-05-server-details.html'">
|
||||
Next
|
||||
</button>
|
||||
</footer>
|
||||
|
||||
|
||||
</body></html>
|
||||
|
After Width: | Height: | Size: 19 KiB |
@@ -0,0 +1,52 @@
|
||||
<!DOCTYPE html><html lang="en"><head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>iRacing - Server Details</title>
|
||||
<link rel="stylesheet" href="common.css">
|
||||
</head>
|
||||
<body data-step="4">
|
||||
|
||||
<header class="header">
|
||||
<div class="step-indicator" data-indicator="server-details">
|
||||
<span>Step</span>
|
||||
<span class="current">4</span>
|
||||
<span>of 18</span>
|
||||
<span>—</span>
|
||||
<span>Server Details</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="main">
|
||||
<h1 class="page-title">Server Details</h1>
|
||||
|
||||
<form id="server-details-form">
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="region">Server Region</label>
|
||||
<select id="region" class="form-select" data-dropdown="region">
|
||||
<option value="us-east">US East</option>
|
||||
<option value="us-west">US West</option>
|
||||
<option value="eu-central">EU Central</option>
|
||||
<option value="eu-west">EU West</option>
|
||||
<option value="asia">Asia</option>
|
||||
<option value="oceania">Oceania</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="toggle-group">
|
||||
<input type="checkbox" id="startNow" class="toggle-input" data-toggle="startNow" style="">
|
||||
<label class="toggle-label" for="startNow">Start session immediately</label>
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
|
||||
<footer class="footer">
|
||||
<button type="button" class="btn btn-secondary" data-action="back" onclick="window.location.href='step-03-create-race.html'">
|
||||
Back
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary" data-action="next" onclick="window.location.href='step-05-server-details.html'">
|
||||
Next
|
||||
</button>
|
||||
</footer>
|
||||
|
||||
|
||||
</body></html>
|
||||
|
After Width: | Height: | Size: 19 KiB |
@@ -0,0 +1,52 @@
|
||||
<!DOCTYPE html><html lang="en"><head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>iRacing - Server Details</title>
|
||||
<link rel="stylesheet" href="common.css">
|
||||
</head>
|
||||
<body data-step="4">
|
||||
|
||||
<header class="header">
|
||||
<div class="step-indicator" data-indicator="server-details">
|
||||
<span>Step</span>
|
||||
<span class="current">4</span>
|
||||
<span>of 18</span>
|
||||
<span>—</span>
|
||||
<span>Server Details</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="main">
|
||||
<h1 class="page-title">Server Details</h1>
|
||||
|
||||
<form id="server-details-form">
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="region">Server Region</label>
|
||||
<select id="region" class="form-select" data-dropdown="region">
|
||||
<option value="us-east">US East</option>
|
||||
<option value="us-west">US West</option>
|
||||
<option value="eu-central">EU Central</option>
|
||||
<option value="eu-west">EU West</option>
|
||||
<option value="asia">Asia</option>
|
||||
<option value="oceania">Oceania</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="toggle-group">
|
||||
<input type="checkbox" id="startNow" class="toggle-input" data-toggle="startNow" style="">
|
||||
<label class="toggle-label" for="startNow">Start session immediately</label>
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
|
||||
<footer class="footer">
|
||||
<button type="button" class="btn btn-secondary" data-action="back" onclick="window.location.href='step-03-create-race.html'">
|
||||
Back
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary" data-action="next" onclick="window.location.href='step-05-server-details.html'">
|
||||
Next
|
||||
</button>
|
||||
</footer>
|
||||
|
||||
|
||||
</body></html>
|
||||
|
After Width: | Height: | Size: 19 KiB |
@@ -0,0 +1,52 @@
|
||||
<!DOCTYPE html><html lang="en"><head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>iRacing - Server Details</title>
|
||||
<link rel="stylesheet" href="common.css">
|
||||
</head>
|
||||
<body data-step="4">
|
||||
|
||||
<header class="header">
|
||||
<div class="step-indicator" data-indicator="server-details">
|
||||
<span>Step</span>
|
||||
<span class="current">4</span>
|
||||
<span>of 18</span>
|
||||
<span>—</span>
|
||||
<span>Server Details</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="main">
|
||||
<h1 class="page-title">Server Details</h1>
|
||||
|
||||
<form id="server-details-form">
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="region">Server Region</label>
|
||||
<select id="region" class="form-select" data-dropdown="region">
|
||||
<option value="us-east">US East</option>
|
||||
<option value="us-west">US West</option>
|
||||
<option value="eu-central">EU Central</option>
|
||||
<option value="eu-west">EU West</option>
|
||||
<option value="asia">Asia</option>
|
||||
<option value="oceania">Oceania</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="toggle-group">
|
||||
<input type="checkbox" id="startNow" class="toggle-input" data-toggle="startNow" style="">
|
||||
<label class="toggle-label" for="startNow">Start session immediately</label>
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
|
||||
<footer class="footer">
|
||||
<button type="button" class="btn btn-secondary" data-action="back" onclick="window.location.href='step-03-create-race.html'">
|
||||
Back
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary" data-action="next" onclick="window.location.href='step-05-server-details.html'">
|
||||
Next
|
||||
</button>
|
||||
</footer>
|
||||
|
||||
|
||||
</body></html>
|
||||
|
After Width: | Height: | Size: 19 KiB |
@@ -0,0 +1,52 @@
|
||||
<!DOCTYPE html><html lang="en"><head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>iRacing - Server Details</title>
|
||||
<link rel="stylesheet" href="common.css">
|
||||
</head>
|
||||
<body data-step="4">
|
||||
|
||||
<header class="header">
|
||||
<div class="step-indicator" data-indicator="server-details">
|
||||
<span>Step</span>
|
||||
<span class="current">4</span>
|
||||
<span>of 18</span>
|
||||
<span>—</span>
|
||||
<span>Server Details</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="main">
|
||||
<h1 class="page-title">Server Details</h1>
|
||||
|
||||
<form id="server-details-form">
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="region">Server Region</label>
|
||||
<select id="region" class="form-select" data-dropdown="region">
|
||||
<option value="us-east">US East</option>
|
||||
<option value="us-west">US West</option>
|
||||
<option value="eu-central">EU Central</option>
|
||||
<option value="eu-west">EU West</option>
|
||||
<option value="asia">Asia</option>
|
||||
<option value="oceania">Oceania</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="toggle-group">
|
||||
<input type="checkbox" id="startNow" class="toggle-input" data-toggle="startNow" style="">
|
||||
<label class="toggle-label" for="startNow">Start session immediately</label>
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
|
||||
<footer class="footer">
|
||||
<button type="button" class="btn btn-secondary" data-action="back" onclick="window.location.href='step-03-create-race.html'">
|
||||
Back
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary" data-action="next" onclick="window.location.href='step-05-server-details.html'">
|
||||
Next
|
||||
</button>
|
||||
</footer>
|
||||
|
||||
|
||||
</body></html>
|
||||
|
After Width: | Height: | Size: 19 KiB |
@@ -0,0 +1,52 @@
|
||||
<!DOCTYPE html><html lang="en"><head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>iRacing - Server Details</title>
|
||||
<link rel="stylesheet" href="common.css">
|
||||
</head>
|
||||
<body data-step="4">
|
||||
|
||||
<header class="header">
|
||||
<div class="step-indicator" data-indicator="server-details">
|
||||
<span>Step</span>
|
||||
<span class="current">4</span>
|
||||
<span>of 18</span>
|
||||
<span>—</span>
|
||||
<span>Server Details</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="main">
|
||||
<h1 class="page-title">Server Details</h1>
|
||||
|
||||
<form id="server-details-form">
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="region">Server Region</label>
|
||||
<select id="region" class="form-select" data-dropdown="region">
|
||||
<option value="us-east">US East</option>
|
||||
<option value="us-west">US West</option>
|
||||
<option value="eu-central">EU Central</option>
|
||||
<option value="eu-west">EU West</option>
|
||||
<option value="asia">Asia</option>
|
||||
<option value="oceania">Oceania</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="toggle-group">
|
||||
<input type="checkbox" id="startNow" class="toggle-input" data-toggle="startNow" style="">
|
||||
<label class="toggle-label" for="startNow">Start session immediately</label>
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
|
||||
<footer class="footer">
|
||||
<button type="button" class="btn btn-secondary" data-action="back" onclick="window.location.href='step-03-create-race.html'">
|
||||
Back
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary" data-action="next" onclick="window.location.href='step-05-server-details.html'">
|
||||
Next
|
||||
</button>
|
||||
</footer>
|
||||
|
||||
|
||||
</body></html>
|
||||
|
After Width: | Height: | Size: 19 KiB |
@@ -0,0 +1 @@
|
||||
<html><head></head><body></body></html>
|
||||
|
After Width: | Height: | Size: 4.2 KiB |
@@ -0,0 +1 @@
|
||||
<html><head></head><body></body></html>
|
||||
|
After Width: | Height: | Size: 4.2 KiB |
@@ -0,0 +1 @@
|
||||
<html><head></head><body></body></html>
|
||||
|
After Width: | Height: | Size: 4.2 KiB |
@@ -4,6 +4,44 @@ This document provides a technical deep-dive into GridPilot's Clean Architecture
|
||||
|
||||
---
|
||||
|
||||
## iRacing Automation Strategy
|
||||
|
||||
**IMPORTANT**: Understanding the distinction between iRacing's interfaces is critical for our automation approach.
|
||||
|
||||
### Two iRacing Interfaces
|
||||
|
||||
1. **iRacing Website (members.iracing.com)**: Standard HTML/DOM web application accessible at `https://members-ng.iracing.com/`. This is where hosted session management lives. Being a standard web application, it can be automated with browser automation tools like **Playwright** or **Puppeteer**. This is 100% legal and our preferred approach.
|
||||
|
||||
2. **iRacing Desktop App (Electron)**: The iRacing desktop application is a sandboxed Electron app. Its DOM is inaccessible, and any modification violates iRacing's Terms of Service. This is why tools like iRefined were shut down.
|
||||
|
||||
### Automation Rules
|
||||
|
||||
**Allowed Approaches:**
|
||||
- ✅ Browser automation of the iRacing website using Playwright/Puppeteer
|
||||
- ✅ Standard DOM manipulation and interaction via browser automation APIs
|
||||
|
||||
**Forbidden Approaches:**
|
||||
- ❌ DOM automation inside the iRacing Electron desktop app
|
||||
- ❌ Script injection into the desktop client
|
||||
- ❌ Any client modification (similar to what got iRefined shut down)
|
||||
|
||||
### Technology Stack
|
||||
|
||||
- **Primary**: Playwright for browser automation of members.iracing.com
|
||||
- **Alternative**: Puppeteer (if Playwright isn't suitable for specific use cases)
|
||||
|
||||
### Development vs Production Mode
|
||||
|
||||
- **Development Mode**: Launches a Playwright-controlled browser to automate the real iRacing website
|
||||
- **Production Mode**: Same as development - browser automation targeting members.iracing.com
|
||||
- **Test Mode**: Uses mocked browser automation (no real browser interaction)
|
||||
|
||||
### HTML Fixtures (resources/iracing-hosted-sessions/)
|
||||
|
||||
The HTML files in `resources/iracing-hosted-sessions/` are **static snapshots for reference and testing**. They help developers understand the iRacing UI structure and serve as fixtures for E2E tests. Production automation always targets the REAL iRacing website at members-ng.iracing.com.
|
||||
|
||||
---
|
||||
|
||||
## 1. Overview
|
||||
|
||||
### Clean Architecture Principles
|
||||
@@ -151,7 +189,7 @@ GridPilot's monorepo structure enforces layer boundaries through directory organ
|
||||
└── /companion # Electron desktop app
|
||||
├── /main # Electron main process
|
||||
├── /renderer # Electron renderer (React)
|
||||
└── /automation # Nut.js browser automation scripts
|
||||
└── /automation # Playwright browser automation scripts
|
||||
```
|
||||
|
||||
### Import Rules (Enforced via ESLint)
|
||||
@@ -639,7 +677,7 @@ function useCreateLeague() {
|
||||
**Main Process (Node.js)**
|
||||
- Handles IPC from renderer process
|
||||
- Invokes use cases (directly calls application layer, not via HTTP)
|
||||
- Manages Nut.js automation workflows
|
||||
- Manages Playwright browser automation workflows
|
||||
|
||||
```typescript
|
||||
// Electron IPC handler
|
||||
@@ -648,7 +686,7 @@ ipcMain.handle('create-iracing-session', async (event, sessionData) => {
|
||||
const result = await useCase.execute(sessionData);
|
||||
|
||||
if (result.requiresAutomation) {
|
||||
// Trigger Nut.js automation
|
||||
// Trigger Playwright browser automation
|
||||
await automationService.createSessionInBrowser(result.sessionDetails);
|
||||
}
|
||||
|
||||
@@ -660,22 +698,38 @@ ipcMain.handle('create-iracing-session', async (event, sessionData) => {
|
||||
- UI for session creation, result monitoring
|
||||
- IPC communication with main process
|
||||
|
||||
**Nut.js Automation** ([`AutomationService`](../src/apps/companion/automation/AutomationService.ts))
|
||||
**Playwright Browser Automation** ([`PlaywrightAutomationAdapter`](../packages/infrastructure/adapters/automation/PlaywrightAutomationAdapter.ts))
|
||||
|
||||
GridPilot uses Playwright for all automation tasks. This is the only automation approach—there is no OS-level automation or fallback.
|
||||
|
||||
```typescript
|
||||
export class AutomationService {
|
||||
import { chromium, Browser, Page } from 'playwright';
|
||||
|
||||
export class PlaywrightAutomationAdapter {
|
||||
private browser: Browser | null = null;
|
||||
private page: Page | null = null;
|
||||
|
||||
async createSessionInBrowser(details: SessionDetails): Promise<void> {
|
||||
// 1. Launch browser via Nut.js
|
||||
await mouse.click(/* iRacing icon position */);
|
||||
// 1. Launch browser via Playwright
|
||||
this.browser = await chromium.launch({ headless: false });
|
||||
this.page = await this.browser.newPage();
|
||||
|
||||
// 2. Navigate to session creation page
|
||||
await keyboard.type('https://members.iracing.com/membersite/CreateSession');
|
||||
// 2. Navigate to iRacing session creation page
|
||||
await this.page.goto('https://members-ng.iracing.com/web/racing/hosted/create');
|
||||
|
||||
// 3. Fill form fields
|
||||
await this.fillField('session-name', details.name);
|
||||
await this.fillField('track', details.track);
|
||||
// 3. Fill form fields using DOM selectors
|
||||
await this.page.fill('[data-testid="session-name"]', details.name);
|
||||
await this.page.selectOption('[data-testid="track-select"]', details.track);
|
||||
|
||||
// 4. Submit form
|
||||
await this.clickButton('create-session');
|
||||
await this.page.click('[data-testid="create-session-button"]');
|
||||
|
||||
// 5. Wait for confirmation
|
||||
await this.page.waitForSelector('[data-testid="session-created-confirmation"]');
|
||||
}
|
||||
|
||||
async cleanup(): Promise<void> {
|
||||
await this.browser?.close();
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -1045,4 +1099,4 @@ test('User creates league via Web Client', async ({ page }) => {
|
||||
|
||||
*This architecture documentation will evolve as GridPilot matures. All changes must maintain Clean Architecture principles and the dependency rule.*
|
||||
|
||||
*Last Updated: 2025-11-21*
|
||||
*Last Updated: 2025-11-23*
|
||||
181
docs/CONCEPT.md
@@ -94,10 +94,10 @@ Based on feedback from Reddit and Discord communities, league organizers are ove
|
||||
- Configure team vs solo racing formats
|
||||
|
||||
**Authentication & Security**
|
||||
- iRacing OAuth integration
|
||||
- Verify driver identities automatically
|
||||
- Zero-knowledge login: GridPilot never sees or stores your password
|
||||
- User performs login in visible browser window
|
||||
- Persistent sessions: login once, stay logged in
|
||||
- Secure access control for league admin functions
|
||||
- No separate account creation needed
|
||||
|
||||
### Migration Support
|
||||
|
||||
@@ -186,6 +186,177 @@ GridPilot acts as an admin assistant, not a bot:
|
||||
|
||||
GridPilot automates league management workflows - creating sessions, processing results, managing registrations. We never touch actual racing gameplay, driver behavior, or in-race activities. This is administrative automation to free organizers from manual work.
|
||||
|
||||
### iRacing Automation Rules
|
||||
|
||||
Understanding the distinction between iRacing's interfaces is critical:
|
||||
|
||||
**Two iRacing Interfaces:**
|
||||
|
||||
1. **iRacing Website (members.iracing.com)**: This is a standard HTML/DOM web application where hosted session management lives. Being a standard web application, it can be automated with browser automation tools like Playwright. This is 100% legal.
|
||||
|
||||
2. **iRacing Desktop App (Electron)**: The racing simulation itself runs in a sandboxed Electron application. Its DOM is inaccessible, and any modification violates iRacing's Terms of Service. This is why tools like iRefined were shut down.
|
||||
|
||||
**Our Approach:**
|
||||
- ✅ **Browser automation of the website** - Playwright automates members.iracing.com for session creation, form filling, and data extraction
|
||||
- ❌ **Never modify the desktop client** - No DOM injection, no script injection, no client modification
|
||||
|
||||
**Why This Matters:**
|
||||
- iRacing explicitly allows third-party tools that interact with their website
|
||||
- Client modification (like iRefined did) violates TOS and risks account bans
|
||||
- Browser automation is reliable, testable, and fully compliant
|
||||
|
||||
## Security & Authentication
|
||||
|
||||
### Zero-Knowledge Login Design
|
||||
|
||||
GridPilot requires access to your iRacing account to automate session creation, but **we never see, store, or transmit your password**. This section explains how we achieve secure authentication while maintaining complete transparency.
|
||||
|
||||
### The Trust Problem
|
||||
|
||||
When an app asks for your credentials, you're trusting that app with your identity. Many apps:
|
||||
- Store passwords in databases (risk of breach)
|
||||
- Transmit credentials through their servers (man-in-the-middle risk)
|
||||
- Have access to your login data internally (insider risk)
|
||||
|
||||
GridPilot takes a fundamentally different approach: **we never have access to your credentials at all**.
|
||||
|
||||
### How GridPilot Authentication Works
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ USER'S COMPUTER │
|
||||
│ ┌───────────────┐ ┌─────────────────────────────────┐ │
|
||||
│ │ GridPilot │ │ Playwright Browser Window │ │
|
||||
│ │ Companion │────────▶│ (Visible to User) │ │
|
||||
│ │ App │ │ │ │
|
||||
│ │ │ │ ┌─────────────────────────┐ │ │
|
||||
│ │ Cannot read │ │ │ iRacing Login Page │ │ │
|
||||
│ │ form inputs │ │ │ members.iracing.com │ │ │
|
||||
│ │ │ │ │ │ │ │
|
||||
│ │ Only detects │ │ │ [Email: *********** ] │ │ │
|
||||
│ │ URL changes │ │ │ [Password: ******** ] │ │ │
|
||||
│ │ │ │ │ [ Sign In ] │ │ │
|
||||
│ └───────────────┘ │ └─────────────────────────┘ │ │
|
||||
│ └─────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
│ HTTPS
|
||||
▼
|
||||
┌─────────────────────┐
|
||||
│ iRacing Servers │
|
||||
│ (Direct connection │
|
||||
│ from browser) │
|
||||
└─────────────────────┘
|
||||
```
|
||||
|
||||
**Step-by-Step Flow:**
|
||||
|
||||
1. **User clicks "Connect to iRacing"** in GridPilot
|
||||
2. **A real browser window opens** showing the iRacing login page
|
||||
3. **User verifies the URL** - it's the real `members.iracing.com`
|
||||
4. **User enters credentials directly** - GridPilot cannot read input fields
|
||||
5. **iRacing authenticates the user** - credentials go directly to iRacing
|
||||
6. **GridPilot detects success** - only by observing the URL change
|
||||
7. **Browser session is saved locally** - for future automation runs
|
||||
|
||||
### What GridPilot CAN and CANNOT Do
|
||||
|
||||
| Capability | GridPilot Access |
|
||||
|------------|------------------|
|
||||
| Read your password | ❌ **Never** |
|
||||
| See what you type | ❌ **Never** |
|
||||
| Intercept credentials | ❌ **Never** |
|
||||
| Know if login succeeded | ✅ By URL change only |
|
||||
| Use authenticated session | ✅ For automation only |
|
||||
| Clear your session | ✅ User-initiated logout |
|
||||
|
||||
### Session Persistence
|
||||
|
||||
After your first login, GridPilot saves the browser session locally on your computer:
|
||||
|
||||
- **Location**: Your user data folder (not transmitted anywhere)
|
||||
- **Contents**: Browser cookies and state (encrypted by OS)
|
||||
- **Benefit**: You don't need to login every time you use GridPilot
|
||||
- **Control**: You can sign out anytime to clear the saved session
|
||||
|
||||
**Session Lifecycle:**
|
||||
|
||||
```
|
||||
First Use Return Visit Session Expired
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌─────────┐ ┌─────────┐ ┌─────────┐
|
||||
│ Login │ │ Check │ │ Detect │
|
||||
│ Required│ │ Session │ │ Expiry │
|
||||
└────┬────┘ └────┬────┘ └────┬────┘
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌─────────┐ ┌─────────┐ ┌─────────┐
|
||||
│ Browser │ │ Valid? │───Yes────▶ │ Ready │
|
||||
│ Login │ └────┬────┘ │ to Use │
|
||||
└────┬────┘ │ No └─────────┘
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌─────────┐ ┌─────────┐ ┌─────────┐
|
||||
│ Save │ │ Re-auth │ │ Pause │
|
||||
│ Session │ │ Prompt │ │ & Prompt│
|
||||
└────┬────┘ └─────────┘ └─────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────┐
|
||||
│ Ready │
|
||||
│ to Use │
|
||||
└─────────┘
|
||||
```
|
||||
|
||||
### User Verification Points
|
||||
|
||||
At every step, you can verify GridPilot's behavior:
|
||||
|
||||
1. **Browser Window**: You see the real browser - not an embedded frame
|
||||
2. **URL Bar**: Verify you're on `members.iracing.com` (look for HTTPS lock)
|
||||
3. **Network Traffic**: Your credentials go directly to iRacing's servers
|
||||
4. **Input Fields**: GridPilot cannot inject JavaScript to read form values
|
||||
5. **Logout Control**: You can sign out from GridPilot at any time
|
||||
|
||||
### Session Expiry During Automation
|
||||
|
||||
If your iRacing session expires while automation is running:
|
||||
|
||||
1. **Automation pauses** - no data loss
|
||||
2. **You're prompted to re-authenticate** - same secure flow
|
||||
3. **Automation resumes** - from where it left off
|
||||
|
||||
GridPilot never stores credentials to "auto-retry" - you always maintain control.
|
||||
|
||||
### Security Guarantees
|
||||
|
||||
| Guarantee | How We Ensure It |
|
||||
|-----------|------------------|
|
||||
| Zero-knowledge | Playwright browser is sandboxed; we cannot inject credential-reading code |
|
||||
| No transmission | Your login happens directly with iRacing; GridPilot servers never see traffic |
|
||||
| Local storage only | Session data stays on your computer, encrypted by your OS |
|
||||
| User control | You can logout, clear sessions, or revoke access anytime |
|
||||
| Transparency | Browser window is visible; you see exactly what's happening |
|
||||
|
||||
### Comparison to Other Approaches
|
||||
|
||||
| Approach | Password Exposure | Risk Level |
|
||||
|----------|-------------------|------------|
|
||||
| **GridPilot (Zero-Knowledge)** | Never exposed | ✅ Minimal |
|
||||
| OAuth (if iRacing offered it) | Never exposed | ✅ Minimal |
|
||||
| Password stored in app | App has access | ⚠️ Moderate |
|
||||
| Password in config file | File has plaintext | ❌ High |
|
||||
|
||||
### Technical Implementation
|
||||
|
||||
For developers interested in the implementation details, see [ARCHITECTURE.md](./ARCHITECTURE.md). Key points:
|
||||
|
||||
- **Playwright BrowserContext**: Manages browser state including cookies
|
||||
- **Persistent Context**: Saved to `app.getPath('userData')` in Electron
|
||||
- **Session Validation**: Navigates to protected page; detects login redirects
|
||||
- **No Credential Ports**: Application layer has no interfaces for password handling
|
||||
|
||||
## Future Vision
|
||||
|
||||
### Monetization Approach
|
||||
@@ -226,4 +397,6 @@ But first: nail the iRacing league management experience.
|
||||
|
||||
---
|
||||
|
||||
GridPilot exists to make league racing accessible and professional for organizers of all sizes, eliminating manual work so communities can focus on what matters: great racing and strong communities.
|
||||
GridPilot exists to make league racing accessible and professional for organizers of all sizes, eliminating manual work so communities can focus on what matters: great racing and strong communities.
|
||||
|
||||
*Last Updated: 2025-11-23*
|
||||
835
docs/MOCK_FIXTURES_DESIGN.md
Normal file
@@ -0,0 +1,835 @@
|
||||
# Mock HTML Fixtures Design Document
|
||||
|
||||
## Overview
|
||||
|
||||
This document specifies simplified mock HTML fixtures with explicit test attributes for browser automation testing. These fixtures replace the current full-page iRacing dumps with lightweight, testable HTML pages that simulate the iRacing hosted session wizard.
|
||||
|
||||
## Purpose
|
||||
|
||||
**Test fixtures for E2E testing** - Simplified HTML pages served by FixtureServer that simulate iRacing's wizard for testing the PlaywrightAutomationAdapter in isolation, without needing access to the real iRacing website.
|
||||
|
||||
## Problem Statement
|
||||
|
||||
Current fixtures in `resources/iracing-hosted-sessions/`:
|
||||
- Full page dumps (~2.4M tokens per file)
|
||||
- React/Chakra UI with obfuscated CSS classes (`css-451i2c`, etc.)
|
||||
- No stable `data-testid` or `data-automation` attributes
|
||||
- Unsuitable for reliable CSS selector-based automation
|
||||
|
||||
## Solution: Simplified Mock Fixtures
|
||||
|
||||
### Design Principles
|
||||
|
||||
1. **Explicit Test Attributes**: Every interactive element has stable `data-*` attributes
|
||||
2. **Minimal HTML**: Only essential structure, no framework artifacts
|
||||
3. **Self-Contained**: Each fixture includes all CSS needed for visual verification
|
||||
4. **Navigation-Aware**: Buttons link to appropriate next/previous fixtures
|
||||
5. **Form Fields Match Domain**: Field names align with `HostedSessionConfig` entity
|
||||
|
||||
---
|
||||
|
||||
## Attribute Schema
|
||||
|
||||
### Core Attributes
|
||||
|
||||
| Attribute | Purpose | Example Values |
|
||||
|-----------|---------|----------------|
|
||||
| `data-step` | Step identification | `2` through `18` |
|
||||
| `data-action` | Navigation/action buttons | `next`, `back`, `confirm`, `cancel`, `create`, `add`, `select` |
|
||||
| `data-field` | Form input fields | `sessionName`, `password`, `description`, `region`, etc. |
|
||||
| `data-modal` | Modal container flag | `true` |
|
||||
| `data-modal-trigger` | Button that opens a modal | `admin`, `car`, `track` |
|
||||
| `data-list` | List container | `admins`, `cars`, `tracks` |
|
||||
| `data-item` | Selectable list item | Car/track/admin ID |
|
||||
| `data-toggle` | Toggle/checkbox element | `startNow`, `teamDriving`, `rollingStart` |
|
||||
| `data-dropdown` | Dropdown select | `region`, `weather`, `trackState`, `carClass` |
|
||||
| `data-slider` | Slider input | `time`, `temperature`, `practice`, `qualify`, `race` |
|
||||
| `data-indicator` | Step indicator | `race-info`, `server-details`, etc. |
|
||||
|
||||
### Navigation Attribute Values
|
||||
|
||||
| Value | Description | Usage |
|
||||
|-------|-------------|-------|
|
||||
| `next` | Proceed to next step | All non-final steps |
|
||||
| `back` | Return to previous step | Steps 3-18 |
|
||||
| `confirm` | Confirm modal action | Modal steps (6, 9, 12) |
|
||||
| `cancel` | Cancel/close modal | Modal steps |
|
||||
| `create` | Create new race | Step 2 |
|
||||
| `add` | Open add modal | Steps 5, 8, 11 |
|
||||
| `select` | Select item from list | Modal list items |
|
||||
|
||||
---
|
||||
|
||||
## Step-by-Step Fixture Specifications
|
||||
|
||||
### Step 1: Login - Handled Externally
|
||||
> Note: Login is handled externally. No fixture needed.
|
||||
|
||||
### Step 2: Hosted Racing - Main Page
|
||||
|
||||
**Purpose**: Landing page with Create a Race button
|
||||
|
||||
**Elements**:
|
||||
```
|
||||
data-step="2"
|
||||
data-indicator="hosted-racing"
|
||||
data-action="create" → Button: Create a Race
|
||||
```
|
||||
|
||||
**Fields**: None
|
||||
|
||||
**Navigation**:
|
||||
- `[data-action="create"]` → Step 3
|
||||
|
||||
---
|
||||
|
||||
### Step 3: Race Information
|
||||
|
||||
**Purpose**: Basic session configuration
|
||||
|
||||
**Elements**:
|
||||
```
|
||||
data-step="3"
|
||||
data-indicator="race-information"
|
||||
data-field="sessionName" → Input: Session name - required
|
||||
data-field="password" → Input: Session password - optional
|
||||
data-field="description" → Textarea: Session description
|
||||
data-action="next" → Button: Next
|
||||
data-action="back" → Button: Back
|
||||
```
|
||||
|
||||
**Fields**:
|
||||
| Field | Type | Required | Domain Property |
|
||||
|-------|------|----------|-----------------|
|
||||
| `sessionName` | text | Yes | `config.sessionName` |
|
||||
| `password` | password | No | `config.password` |
|
||||
| `description` | textarea | No | `config.description` |
|
||||
|
||||
---
|
||||
|
||||
### Step 4: Server Details
|
||||
|
||||
**Purpose**: Server region and timing configuration
|
||||
|
||||
**Elements**:
|
||||
```
|
||||
data-step="4"
|
||||
data-indicator="server-details"
|
||||
data-dropdown="region" → Select: Server region
|
||||
data-toggle="startNow" → Checkbox: Start immediately
|
||||
data-action="next"
|
||||
data-action="back"
|
||||
```
|
||||
|
||||
**Fields**:
|
||||
| Field | Type | Options |
|
||||
|-------|------|---------|
|
||||
| `region` | dropdown | `us-east`, `us-west`, `eu-central`, `eu-west`, `asia`, `oceania` |
|
||||
| `startNow` | toggle | Boolean |
|
||||
|
||||
---
|
||||
|
||||
### Step 5: Set Admins
|
||||
|
||||
**Purpose**: Admin list management
|
||||
|
||||
**Elements**:
|
||||
```
|
||||
data-step="5"
|
||||
data-indicator="set-admins"
|
||||
data-list="admins" → Container: Admin list
|
||||
data-modal-trigger="admin" → Button: Add Admin
|
||||
data-action="next"
|
||||
data-action="back"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Step 6: Add an Admin - Modal
|
||||
|
||||
**Purpose**: Search and select admin to add
|
||||
|
||||
**Elements**:
|
||||
```
|
||||
data-step="6"
|
||||
data-modal="true"
|
||||
data-indicator="add-admin"
|
||||
data-field="adminSearch" → Input: Search admins
|
||||
data-list="adminResults" → Container: Search results
|
||||
data-item="{adminId}" → Each result item
|
||||
data-action="select" → Button: Select admin
|
||||
data-action="confirm" → Button: Add Selected
|
||||
data-action="cancel" → Button: Cancel
|
||||
```
|
||||
|
||||
**Fields**:
|
||||
| Field | Type | Purpose |
|
||||
|-------|------|---------|
|
||||
| `adminSearch` | text | Filter admin list |
|
||||
|
||||
---
|
||||
|
||||
### Step 7: Time Limits
|
||||
|
||||
**Purpose**: Practice, qualify, and race duration settings
|
||||
|
||||
**Elements**:
|
||||
```
|
||||
data-step="7"
|
||||
data-indicator="time-limits"
|
||||
data-slider="practice" → Range: Practice length in minutes
|
||||
data-slider="qualify" → Range: Qualify length in minutes
|
||||
data-slider="race" → Range: Race length in laps or minutes
|
||||
data-toggle="unlimitedTime" → Checkbox: Unlimited time
|
||||
data-action="next"
|
||||
data-action="back"
|
||||
```
|
||||
|
||||
**Fields**:
|
||||
| Field | Type | Range | Default |
|
||||
|-------|------|-------|---------|
|
||||
| `practice` | slider | 0-120 min | 15 |
|
||||
| `qualify` | slider | 0-60 min | 10 |
|
||||
| `race` | slider | 1-500 laps | 20 |
|
||||
|
||||
---
|
||||
|
||||
### Step 8: Set Cars
|
||||
|
||||
**Purpose**: Car list management
|
||||
|
||||
**Elements**:
|
||||
```
|
||||
data-step="8"
|
||||
data-indicator="set-cars"
|
||||
data-list="cars" → Container: Selected cars
|
||||
data-modal-trigger="car" → Button: Add Car
|
||||
data-action="next"
|
||||
data-action="back"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Step 9: Add a Car - Modal
|
||||
|
||||
**Purpose**: Search and select cars
|
||||
|
||||
**Elements**:
|
||||
```
|
||||
data-step="9"
|
||||
data-modal="true"
|
||||
data-indicator="add-car"
|
||||
data-field="carSearch" → Input: Search cars
|
||||
data-list="carResults" → Container: Car grid
|
||||
data-item="{carId}" → Each car tile
|
||||
data-action="select" → Select car
|
||||
data-action="confirm" → Button: Add Selected
|
||||
data-action="cancel" → Button: Cancel
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Step 10: Set Car Classes
|
||||
|
||||
**Purpose**: Multi-class race configuration
|
||||
|
||||
**Elements**:
|
||||
```
|
||||
data-step="10"
|
||||
data-indicator="car-classes"
|
||||
data-dropdown="carClass" → Select: Car class assignment
|
||||
data-list="classAssignments" → Container: Class assignments
|
||||
data-action="next"
|
||||
data-action="back"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Step 11: Set Track
|
||||
|
||||
**Purpose**: Track selection
|
||||
|
||||
**Elements**:
|
||||
```
|
||||
data-step="11"
|
||||
data-indicator="set-track"
|
||||
data-field="selectedTrack" → Display: Currently selected track
|
||||
data-modal-trigger="track" → Button: Select Track
|
||||
data-action="next"
|
||||
data-action="back"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Step 12: Add a Track - Modal
|
||||
|
||||
**Purpose**: Search and select track
|
||||
|
||||
**Elements**:
|
||||
```
|
||||
data-step="12"
|
||||
data-modal="true"
|
||||
data-indicator="add-track"
|
||||
data-field="trackSearch" → Input: Search tracks
|
||||
data-list="trackResults" → Container: Track grid
|
||||
data-item="{trackId}" → Each track tile
|
||||
data-action="select" → Select track
|
||||
data-action="confirm" → Button: Select
|
||||
data-action="cancel" → Button: Cancel
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Step 13: Track Options
|
||||
|
||||
**Purpose**: Track configuration selection
|
||||
|
||||
**Elements**:
|
||||
```
|
||||
data-step="13"
|
||||
data-indicator="track-options"
|
||||
data-dropdown="trackConfig" → Select: Track configuration
|
||||
data-toggle="dynamicTrack" → Checkbox: Dynamic track
|
||||
data-action="next"
|
||||
data-action="back"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Step 14: Time of Day
|
||||
|
||||
**Purpose**: Race start time configuration
|
||||
|
||||
**Elements**:
|
||||
```
|
||||
data-step="14"
|
||||
data-indicator="time-of-day"
|
||||
data-slider="timeOfDay" → Range: Time of day 0-24
|
||||
data-field="raceDate" → Date picker: Race date
|
||||
data-toggle="simulatedTime" → Checkbox: Simulated time progression
|
||||
data-action="next"
|
||||
data-action="back"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Step 15: Weather
|
||||
|
||||
**Purpose**: Weather conditions
|
||||
|
||||
**Elements**:
|
||||
```
|
||||
data-step="15"
|
||||
data-indicator="weather"
|
||||
data-dropdown="weatherType" → Select: Weather type
|
||||
data-slider="temperature" → Range: Temperature
|
||||
data-slider="humidity" → Range: Humidity
|
||||
data-toggle="dynamicWeather" → Checkbox: Dynamic weather
|
||||
data-action="next"
|
||||
data-action="back"
|
||||
```
|
||||
|
||||
**Weather Types**: `clear`, `partly-cloudy`, `mostly-cloudy`, `overcast`
|
||||
|
||||
---
|
||||
|
||||
### Step 16: Race Options
|
||||
|
||||
**Purpose**: Race rules and settings
|
||||
|
||||
**Elements**:
|
||||
```
|
||||
data-step="16"
|
||||
data-indicator="race-options"
|
||||
data-field="maxDrivers" → Input: Maximum drivers
|
||||
data-toggle="rollingStart" → Checkbox: Rolling start
|
||||
data-toggle="fullCourseCautions" → Checkbox: Full course cautions
|
||||
data-toggle="fastRepairs" → Checkbox: Fast repairs
|
||||
data-action="next"
|
||||
data-action="back"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Step 17: Team Driving
|
||||
|
||||
**Purpose**: Team race configuration
|
||||
|
||||
**Elements**:
|
||||
```
|
||||
data-step="17"
|
||||
data-indicator="team-driving"
|
||||
data-toggle="teamDriving" → Checkbox: Enable team driving
|
||||
data-field="minDrivers" → Input: Min drivers per team
|
||||
data-field="maxDrivers" → Input: Max drivers per team
|
||||
data-action="next"
|
||||
data-action="back"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Step 18: Track Conditions - Final Step
|
||||
|
||||
**Purpose**: Track state configuration
|
||||
|
||||
**Elements**:
|
||||
```
|
||||
data-step="18"
|
||||
data-indicator="track-conditions"
|
||||
data-dropdown="trackState" → Select: Track state
|
||||
data-toggle="marbles" → Checkbox: Marbles simulation
|
||||
data-slider="rubberLevel" → Range: Rubber buildup
|
||||
data-action="back" → Button: Back
|
||||
```
|
||||
|
||||
**Track States**: `auto-generated`, `clean`, `low-rubber`, `medium-rubber`, `high-rubber`
|
||||
|
||||
> **Note**: No Submit button on Step 18. Automation intentionally stops here for user review.
|
||||
|
||||
---
|
||||
|
||||
## Navigation Flow Diagram
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
S2[Step 2: Hosted Racing] -->|Create Race| S3[Step 3: Race Information]
|
||||
S3 -->|Next| S4[Step 4: Server Details]
|
||||
S4 -->|Next| S5[Step 5: Set Admins]
|
||||
S5 -->|Add Admin| S6[Step 6: Add Admin Modal]
|
||||
S6 -->|Confirm/Cancel| S5
|
||||
S5 -->|Next| S7[Step 7: Time Limits]
|
||||
S7 -->|Next| S8[Step 8: Set Cars]
|
||||
S8 -->|Add Car| S9[Step 9: Add Car Modal]
|
||||
S9 -->|Confirm/Cancel| S8
|
||||
S8 -->|Next| S10[Step 10: Car Classes]
|
||||
S10 -->|Next| S11[Step 11: Set Track]
|
||||
S11 -->|Select Track| S12[Step 12: Add Track Modal]
|
||||
S12 -->|Confirm/Cancel| S11
|
||||
S11 -->|Next| S13[Step 13: Track Options]
|
||||
S13 -->|Next| S14[Step 14: Time of Day]
|
||||
S14 -->|Next| S15[Step 15: Weather]
|
||||
S15 -->|Next| S16[Step 16: Race Options]
|
||||
S16 -->|Next| S17[Step 17: Team Driving]
|
||||
S17 -->|Next| S18[Step 18: Track Conditions]
|
||||
S18 -->|STOP| REVIEW[Manual Review Required]
|
||||
|
||||
S3 -.->|Back| S2
|
||||
S4 -.->|Back| S3
|
||||
S5 -.->|Back| S4
|
||||
S7 -.->|Back| S5
|
||||
S8 -.->|Back| S7
|
||||
S10 -.->|Back| S8
|
||||
S11 -.->|Back| S10
|
||||
S13 -.->|Back| S11
|
||||
S14 -.->|Back| S13
|
||||
S15 -.->|Back| S14
|
||||
S16 -.->|Back| S15
|
||||
S17 -.->|Back| S16
|
||||
S18 -.->|Back| S17
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Example Fixture: Step 3 - Race Information
|
||||
|
||||
```html
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>iRacing - Race Information</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, sans-serif;
|
||||
background: #1a1a2e;
|
||||
color: #eee;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.header {
|
||||
background: #16213e;
|
||||
padding: 16px 24px;
|
||||
border-bottom: 1px solid #0f3460;
|
||||
}
|
||||
.step-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
color: #888;
|
||||
}
|
||||
.step-indicator .current {
|
||||
color: #e94560;
|
||||
font-weight: bold;
|
||||
}
|
||||
.main {
|
||||
flex: 1;
|
||||
padding: 32px 24px;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
.page-title {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.form-label {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
color: #aaa;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.form-label.required::after {
|
||||
content: " *";
|
||||
color: #e94560;
|
||||
}
|
||||
.form-input {
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
background: #16213e;
|
||||
border: 1px solid #0f3460;
|
||||
border-radius: 4px;
|
||||
color: #eee;
|
||||
font-size: 16px;
|
||||
}
|
||||
.form-input:focus {
|
||||
outline: none;
|
||||
border-color: #e94560;
|
||||
}
|
||||
textarea.form-input {
|
||||
min-height: 100px;
|
||||
resize: vertical;
|
||||
}
|
||||
.footer {
|
||||
background: #16213e;
|
||||
padding: 16px 24px;
|
||||
border-top: 1px solid #0f3460;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.btn {
|
||||
padding: 12px 24px;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.btn-primary {
|
||||
background: #e94560;
|
||||
color: white;
|
||||
}
|
||||
.btn-primary:hover {
|
||||
background: #ff6b6b;
|
||||
}
|
||||
.btn-secondary {
|
||||
background: transparent;
|
||||
color: #aaa;
|
||||
border: 1px solid #0f3460;
|
||||
}
|
||||
.btn-secondary:hover {
|
||||
background: #0f3460;
|
||||
color: #eee;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body data-step="3">
|
||||
|
||||
<header class="header">
|
||||
<div class="step-indicator" data-indicator="race-information">
|
||||
<span>Step</span>
|
||||
<span class="current">3</span>
|
||||
<span>of 18</span>
|
||||
<span>—</span>
|
||||
<span>Race Information</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="main">
|
||||
<h1 class="page-title">Race Information</h1>
|
||||
|
||||
<form id="race-info-form">
|
||||
<div class="form-group">
|
||||
<label class="form-label required" for="sessionName">Session Name</label>
|
||||
<input
|
||||
type="text"
|
||||
id="sessionName"
|
||||
class="form-input"
|
||||
data-field="sessionName"
|
||||
placeholder="Enter session name"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="password">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
class="form-input"
|
||||
data-field="password"
|
||||
placeholder="Optional password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="description">Description</label>
|
||||
<textarea
|
||||
id="description"
|
||||
class="form-input"
|
||||
data-field="description"
|
||||
placeholder="Optional session description"
|
||||
></textarea>
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
|
||||
<footer class="footer">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
data-action="back"
|
||||
onclick="window.location.href='step-02-hosted-racing.html'"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
data-action="next"
|
||||
onclick="window.location.href='step-04-server-details.html'"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</footer>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Selector Strategy for PlaywrightAutomationAdapter
|
||||
|
||||
### Primary Selector Pattern
|
||||
|
||||
Use **data-* attribute selectors** as the primary strategy:
|
||||
|
||||
```typescript
|
||||
// Selector constants
|
||||
const SELECTORS = {
|
||||
// Step identification
|
||||
stepContainer: (step: number) => `[data-step="${step}"]`,
|
||||
stepIndicator: (name: string) => `[data-indicator="${name}"]`,
|
||||
|
||||
// Navigation
|
||||
nextButton: '[data-action="next"]',
|
||||
backButton: '[data-action="back"]',
|
||||
confirmButton: '[data-action="confirm"]',
|
||||
cancelButton: '[data-action="cancel"]',
|
||||
createButton: '[data-action="create"]',
|
||||
addButton: '[data-action="add"]',
|
||||
selectButton: '[data-action="select"]',
|
||||
|
||||
// Form fields
|
||||
field: (name: string) => `[data-field="${name}"]`,
|
||||
dropdown: (name: string) => `[data-dropdown="${name}"]`,
|
||||
toggle: (name: string) => `[data-toggle="${name}"]`,
|
||||
slider: (name: string) => `[data-slider="${name}"]`,
|
||||
|
||||
// Modals
|
||||
modal: '[data-modal="true"]',
|
||||
modalTrigger: (type: string) => `[data-modal-trigger="${type}"]`,
|
||||
|
||||
// Lists and items
|
||||
list: (name: string) => `[data-list="${name}"]`,
|
||||
listItem: (id: string) => `[data-item="${id}"]`,
|
||||
};
|
||||
```
|
||||
|
||||
### PlaywrightAutomationAdapter Integration
|
||||
|
||||
```typescript
|
||||
import { Page } from 'playwright';
|
||||
|
||||
export class PlaywrightAutomationAdapter implements IScreenAutomation {
|
||||
private page: Page;
|
||||
|
||||
async waitForStep(stepNumber: number): Promise<void> {
|
||||
await this.page.waitForSelector(`[data-step="${stepNumber}"]`, {
|
||||
state: 'visible',
|
||||
timeout: 10000,
|
||||
});
|
||||
}
|
||||
|
||||
async clickAction(action: string): Promise<ClickResult> {
|
||||
const selector = `[data-action="${action}"]`;
|
||||
await this.page.click(selector);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async fillField(fieldName: string, value: string): Promise<FormFillResult> {
|
||||
const selector = `[data-field="${fieldName}"]`;
|
||||
await this.page.fill(selector, value);
|
||||
return { success: true, fieldName, value };
|
||||
}
|
||||
|
||||
async selectDropdown(name: string, value: string): Promise<void> {
|
||||
const selector = `[data-dropdown="${name}"]`;
|
||||
await this.page.selectOption(selector, value);
|
||||
}
|
||||
|
||||
async setToggle(name: string, checked: boolean): Promise<void> {
|
||||
const selector = `[data-toggle="${name}"]`;
|
||||
const isChecked = await this.page.isChecked(selector);
|
||||
if (isChecked !== checked) {
|
||||
await this.page.click(selector);
|
||||
}
|
||||
}
|
||||
|
||||
async setSlider(name: string, value: number): Promise<void> {
|
||||
const selector = `[data-slider="${name}"]`;
|
||||
await this.page.fill(selector, String(value));
|
||||
}
|
||||
|
||||
async waitForModal(): Promise<void> {
|
||||
await this.page.waitForSelector('[data-modal="true"]', {
|
||||
state: 'visible',
|
||||
});
|
||||
}
|
||||
|
||||
async selectListItem(itemId: string): Promise<void> {
|
||||
const selector = `[data-item="${itemId}"]`;
|
||||
await this.page.click(selector);
|
||||
}
|
||||
|
||||
async executeStep(stepId: StepId, config: SessionConfig): Promise<AutomationResult> {
|
||||
const step = stepId.value;
|
||||
await this.waitForStep(step);
|
||||
|
||||
switch (step) {
|
||||
case 2:
|
||||
await this.clickAction('create');
|
||||
break;
|
||||
|
||||
case 3:
|
||||
await this.fillField('sessionName', config.sessionName);
|
||||
if (config.password) {
|
||||
await this.fillField('password', config.password);
|
||||
}
|
||||
if (config.description) {
|
||||
await this.fillField('description', config.description);
|
||||
}
|
||||
await this.clickAction('next');
|
||||
break;
|
||||
|
||||
// Additional steps follow same pattern...
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Selector Priority Order
|
||||
|
||||
1. **`data-action`** - For all clickable navigation elements
|
||||
2. **`data-field`** - For all form inputs
|
||||
3. **`data-step`** - For step identification/verification
|
||||
4. **`data-modal`** - For modal detection
|
||||
5. **`data-item`** - For list item selection
|
||||
|
||||
### Benefits of This Strategy
|
||||
|
||||
1. **Stability**: Selectors will not break when CSS/styling changes
|
||||
2. **Clarity**: Self-documenting selectors indicate purpose
|
||||
3. **Consistency**: Same pattern across all steps
|
||||
4. **Testability**: Easy to verify correct element targeting
|
||||
5. **Maintenance**: Simple to update when workflow changes
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
resources/
|
||||
└── mock-fixtures/ # NEW: Simplified test fixtures
|
||||
├── step-02-hosted-racing.html
|
||||
├── step-03-race-information.html
|
||||
├── step-04-server-details.html
|
||||
├── step-05-set-admins.html
|
||||
├── step-06-add-admin.html
|
||||
├── step-07-time-limits.html
|
||||
├── step-08-set-cars.html
|
||||
├── step-09-add-car.html
|
||||
├── step-10-car-classes.html
|
||||
├── step-11-set-track.html
|
||||
├── step-12-add-track.html
|
||||
├── step-13-track-options.html
|
||||
├── step-14-time-of-day.html
|
||||
├── step-15-weather.html
|
||||
├── step-16-race-options.html
|
||||
├── step-17-team-driving.html
|
||||
├── step-18-track-conditions.html
|
||||
└── shared.css # Optional: Shared styles
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## FixtureServer Updates
|
||||
|
||||
Update `STEP_TO_FIXTURE` mapping in [`FixtureServer.ts`](../packages/infrastructure/adapters/automation/FixtureServer.ts:16):
|
||||
|
||||
```typescript
|
||||
const STEP_TO_FIXTURE: Record<number, string> = {
|
||||
2: 'step-02-hosted-racing.html',
|
||||
3: 'step-03-race-information.html',
|
||||
4: 'step-04-server-details.html',
|
||||
5: 'step-05-set-admins.html',
|
||||
6: 'step-06-add-admin.html',
|
||||
7: 'step-07-time-limits.html',
|
||||
8: 'step-08-set-cars.html',
|
||||
9: 'step-09-add-car.html',
|
||||
10: 'step-10-car-classes.html',
|
||||
11: 'step-11-set-track.html',
|
||||
12: 'step-12-add-track.html',
|
||||
13: 'step-13-track-options.html',
|
||||
14: 'step-14-time-of-day.html',
|
||||
15: 'step-15-weather.html',
|
||||
16: 'step-16-race-options.html',
|
||||
17: 'step-17-team-driving.html',
|
||||
18: 'step-18-track-conditions.html',
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Tasks for Code Mode
|
||||
|
||||
1. Create `resources/mock-fixtures/` directory
|
||||
2. Create 17 HTML fixture files for steps 2-18
|
||||
3. Update [`FixtureServer.ts`](../packages/infrastructure/adapters/automation/FixtureServer.ts:42) constructor to use new fixtures path
|
||||
4. Create `PlaywrightAutomationAdapter` implementing selector strategy
|
||||
5. Update E2E tests to use PlaywrightAutomationAdapter with FixtureServer
|
||||
|
||||
---
|
||||
|
||||
## Testing Verification Checklist
|
||||
|
||||
For each fixture, verify:
|
||||
|
||||
- [ ] `data-step` attribute present on body
|
||||
- [ ] `data-indicator` present for step identification
|
||||
- [ ] All navigation buttons have `data-action`
|
||||
- [ ] All form fields have `data-field`, `data-dropdown`, `data-toggle`, or `data-slider`
|
||||
- [ ] Modal fixtures have `data-modal="true"`
|
||||
- [ ] Navigation links point to correct next/previous fixtures
|
||||
- [ ] Visual rendering is acceptable in browser
|
||||
@@ -39,12 +39,13 @@ This roadmap provides a phased implementation plan for GridPilot, an automated l
|
||||
- [ ] Document development setup in README.md
|
||||
|
||||
### Automation Validation
|
||||
- [ ] Install and configure Nut.js for browser automation
|
||||
- [ ] Test iRacing session creation page detection
|
||||
- [ ] Install and configure Playwright for browser automation
|
||||
- [ ] Test iRacing website navigation and authentication flow
|
||||
- [ ] Test session creation page detection on members.iracing.com
|
||||
- [ ] Test session ID extraction from URL or page elements
|
||||
- [ ] Validate server-side result polling from iRacing API
|
||||
- [ ] Create proof-of-concept automation script
|
||||
- [ ] Document automation approach and limitations
|
||||
- [ ] Create proof-of-concept automation script using Playwright
|
||||
- [ ] Document browser automation approach and iRacing automation rules
|
||||
- [ ] Identify automation failure modes and mitigation strategies
|
||||
|
||||
### Testing Foundation
|
||||
@@ -187,7 +188,7 @@ This roadmap provides a phased implementation plan for GridPilot, an automated l
|
||||
|
||||
### Companion App Foundation
|
||||
- [ ] Set up Electron application structure
|
||||
- [ ] Implement Nut.js browser automation framework
|
||||
- [ ] Implement Playwright browser automation framework
|
||||
- [ ] Create IPC bridge for backend communication
|
||||
- [ ] Build auto-updater mechanism
|
||||
- [ ] Set up application signing and packaging
|
||||
@@ -460,6 +461,6 @@ This roadmap is a living document and will be updated as the project evolves. Ke
|
||||
|
||||
---
|
||||
|
||||
**Last Updated:** 2025-11-21
|
||||
**Current Phase:** Phase 0 (Foundation)
|
||||
**Overall Progress:** 0% (not started)
|
||||
**Last Updated:** 2025-11-23
|
||||
**Current Phase:** Phase 0 (Foundation)
|
||||
**Overall Progress:** In Progress (browser automation implemented)
|
||||
22
docs/TECH.md
@@ -123,10 +123,19 @@ Two candidates meet accessibility and performance requirements:
|
||||
- **Rationale:** Cross-platform desktop framework (Windows, macOS, Linux). Native OS integration (system tray, notifications, auto-start).
|
||||
- **Security:** Isolated renderer processes, context bridge for IPC
|
||||
|
||||
### Nut.js
|
||||
- **Purpose:** Keyboard/mouse control for browser automation
|
||||
- **Rationale:** Simulates human interaction with iRacing web UI when official API unavailable. Not gameplay automation—assistant for data entry tasks.
|
||||
- **Constraints:** Windows-only initially (iRacing primary platform)
|
||||
### Playwright (Browser Automation)
|
||||
- **Purpose:** Automate the iRacing website (members.iracing.com) via DOM manipulation
|
||||
- **Rationale:** Standard browser automation for the iRacing web interface. The iRacing website is a standard HTML/DOM application that can be automated with Playwright. This is 100% legal and the only approach GridPilot uses.
|
||||
- **Key Features:**
|
||||
- Cross-browser support (Chromium, Firefox, WebKit)
|
||||
- Auto-wait for elements (eliminates flaky tests)
|
||||
- Network interception for testing
|
||||
- Screenshot/video capture for debugging
|
||||
|
||||
### Important: iRacing Automation Rules
|
||||
- **✅ Allowed:** Browser automation of members.iracing.com (standard web application)
|
||||
- **❌ Forbidden:** DOM automation inside the iRacing Electron desktop app (TOS violation)
|
||||
- **❌ Forbidden:** Script injection or client modification (like iRefined)
|
||||
|
||||
### Electron IPC
|
||||
- **Main ↔ Renderer:** Type-safe message passing via preload scripts
|
||||
@@ -140,6 +149,7 @@ Two candidates meet accessibility and performance requirements:
|
||||
- Assistant-style automation (user-initiated), not gameplay bots
|
||||
- Complements web app (handles tasks iRacing API doesn't expose)
|
||||
- Desktop integration (notifications for upcoming races, quick access via system tray)
|
||||
- Browser automation is reliable and testable
|
||||
|
||||
## 7. Testing Tools
|
||||
|
||||
@@ -243,7 +253,7 @@ Two candidates meet accessibility and performance requirements:
|
||||
- Runtime: Node.js 20+
|
||||
- Database: PostgreSQL 15+
|
||||
- Auth: iRacing OAuth + JWT
|
||||
- Companion: Electron + Nut.js
|
||||
- Companion: Electron + Playwright (browser automation only)
|
||||
- Testing: Vitest + Playwright + Test Containers
|
||||
- Infra: Docker + Redis + S3/MinIO
|
||||
- Monorepo: npm workspaces
|
||||
@@ -271,4 +281,4 @@ Two candidates meet accessibility and performance requirements:
|
||||
|
||||
---
|
||||
|
||||
*Last Updated: 2025-11-21*
|
||||
*Last Updated: 2025-11-23*
|
||||
2430
package-lock.json
generated
@@ -27,14 +27,22 @@
|
||||
"chrome:debug": "open -a 'Google Chrome' --args --remote-debugging-port=9222 --user-data-dir=/tmp/chrome-debug",
|
||||
"docker:e2e:up": "docker-compose -f docker/docker-compose.e2e.yml up -d",
|
||||
"docker:e2e:down": "docker-compose -f docker/docker-compose.e2e.yml down",
|
||||
"generate-templates": "npx tsx scripts/generate-templates/index.ts",
|
||||
"prepare": "husky"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cucumber/cucumber": "^11.0.1",
|
||||
"@playwright/test": "^1.40.0",
|
||||
"@vitest/ui": "^2.1.8",
|
||||
"husky": "^9.1.7",
|
||||
"playwright": "^1.40.0",
|
||||
"puppeteer": "^24.31.0",
|
||||
"tsx": "^4.7.0",
|
||||
"typescript": "^5.7.2",
|
||||
"vitest": "^2.1.8"
|
||||
},
|
||||
"dependencies": {
|
||||
"playwright-extra": "^4.3.6",
|
||||
"puppeteer-extra-plugin-stealth": "^2.11.2"
|
||||
}
|
||||
}
|
||||
|
||||
45
packages/application/ports/IAuthenticationService.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { AuthenticationState } from '../../domain/value-objects/AuthenticationState';
|
||||
import { Result } from '../../shared/result/Result';
|
||||
|
||||
/**
|
||||
* Port for authentication services implementing zero-knowledge login.
|
||||
*
|
||||
* GridPilot never sees, stores, or transmits user credentials.
|
||||
* Authentication is handled by opening a visible browser window where
|
||||
* the user logs in directly with iRacing. GridPilot only observes
|
||||
* URL changes to detect successful authentication.
|
||||
*/
|
||||
export interface IAuthenticationService {
|
||||
/**
|
||||
* Check if user has a valid session without prompting login.
|
||||
* Navigates to a protected iRacing page and checks for login redirects.
|
||||
*
|
||||
* @returns Result containing the current authentication state
|
||||
*/
|
||||
checkSession(): Promise<Result<AuthenticationState>>;
|
||||
|
||||
/**
|
||||
* Open browser for user to login manually.
|
||||
* The browser window is visible so user can verify they're on the real iRacing site.
|
||||
* GridPilot waits for URL change indicating successful login.
|
||||
*
|
||||
* @returns Result indicating success (login complete) or failure (cancelled/timeout)
|
||||
*/
|
||||
initiateLogin(): Promise<Result<void>>;
|
||||
|
||||
/**
|
||||
* Clear the persistent session (logout).
|
||||
* Removes stored browser context and cookies.
|
||||
*
|
||||
* @returns Result indicating success or failure
|
||||
*/
|
||||
clearSession(): Promise<Result<void>>;
|
||||
|
||||
/**
|
||||
* Get current authentication state.
|
||||
* Returns cached state without making network requests.
|
||||
*
|
||||
* @returns The current AuthenticationState
|
||||
*/
|
||||
getState(): AuthenticationState;
|
||||
}
|
||||
@@ -1,6 +1,4 @@
|
||||
import { StepId } from '../../domain/value-objects/StepId';
|
||||
import type { ImageTemplate } from '../../domain/value-objects/ImageTemplate';
|
||||
import type { ElementLocation, LoginDetectionResult, ScreenRegion } from '../../domain/value-objects/ScreenRegion';
|
||||
import {
|
||||
NavigationResult,
|
||||
FormFillResult,
|
||||
@@ -11,77 +9,35 @@ import {
|
||||
} from './AutomationResults';
|
||||
|
||||
/**
|
||||
* Screen capture result containing the captured image data.
|
||||
* Browser automation interface for Playwright-based automation.
|
||||
*
|
||||
* This interface defines the contract for browser automation using
|
||||
* standard DOM manipulation via Playwright. All automation is done
|
||||
* through browser DevTools protocol - no OS-level automation.
|
||||
*/
|
||||
export interface ScreenCaptureResult {
|
||||
success: boolean;
|
||||
data?: Buffer;
|
||||
width?: number;
|
||||
height?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Window information for browser window management.
|
||||
*/
|
||||
export interface WindowInfo {
|
||||
title: string;
|
||||
bounds: ScreenRegion;
|
||||
handle: number;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of window focus operation.
|
||||
*/
|
||||
export interface WindowFocusResult {
|
||||
success: boolean;
|
||||
window?: WindowInfo;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extended browser automation interface for OS-level screen automation.
|
||||
*
|
||||
* This interface extends the base IBrowserAutomation with screen recognition
|
||||
* capabilities for TOS-compliant automation that doesn't use browser DevTools.
|
||||
*
|
||||
* Key differences from browser-based automation:
|
||||
* - Uses image template matching instead of CSS selectors
|
||||
* - Works with screen coordinates instead of DOM elements
|
||||
* - Requires explicit window focus management
|
||||
* - No direct access to page content or JavaScript execution
|
||||
*/
|
||||
export interface IScreenAutomation {
|
||||
// ============================================
|
||||
// LEGACY BROWSER AUTOMATION METHODS
|
||||
// (Maintained for backward compatibility)
|
||||
// ============================================
|
||||
|
||||
export interface IBrowserAutomation {
|
||||
/**
|
||||
* Navigate to a URL using keyboard shortcuts (Cmd/Ctrl+L, type URL, Enter).
|
||||
* Requires browser window to be focused.
|
||||
* Navigate to a URL.
|
||||
*/
|
||||
navigateToPage(url: string): Promise<NavigationResult>;
|
||||
|
||||
/**
|
||||
* Fill a form field by selecting all text and typing new value.
|
||||
* Requires the field to already be focused.
|
||||
* Fill a form field by name or selector.
|
||||
*/
|
||||
fillFormField(fieldName: string, value: string): Promise<FormFillResult>;
|
||||
|
||||
/**
|
||||
* Click at a screen position (accepts coordinates or template ID).
|
||||
* Click an element by selector or action name.
|
||||
*/
|
||||
clickElement(target: string): Promise<ClickResult>;
|
||||
|
||||
/**
|
||||
* Wait for a condition (time-based in screen automation mode).
|
||||
* Wait for an element to appear.
|
||||
*/
|
||||
waitForElement(target: string, maxWaitMs?: number): Promise<WaitResult>;
|
||||
|
||||
/**
|
||||
* Handle modal dialogs using keyboard (Enter/Escape).
|
||||
* Handle modal dialogs.
|
||||
*/
|
||||
handleModal(stepId: StepId, action: string): Promise<ModalResult>;
|
||||
|
||||
@@ -91,90 +47,24 @@ export interface IScreenAutomation {
|
||||
executeStep?(stepId: StepId, config: Record<string, unknown>): Promise<AutomationResult>;
|
||||
|
||||
/**
|
||||
* Initialize the automation connection.
|
||||
* Initialize the browser connection.
|
||||
* Returns an AutomationResult indicating success or failure.
|
||||
*/
|
||||
connect?(): Promise<AutomationResult>;
|
||||
|
||||
/**
|
||||
* Clean up resources.
|
||||
* Clean up browser resources.
|
||||
*/
|
||||
disconnect?(): Promise<void>;
|
||||
|
||||
/**
|
||||
* Check if automation is ready.
|
||||
* Check if browser is connected and ready.
|
||||
*/
|
||||
isConnected?(): boolean;
|
||||
|
||||
// ============================================
|
||||
// SCREEN AUTOMATION METHODS
|
||||
// (New methods for OS-level automation)
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Detect login state by searching for known UI indicators on screen.
|
||||
* Uses template matching to find login-related elements.
|
||||
*
|
||||
* @returns LoginDetectionResult with confidence and detected indicators
|
||||
*/
|
||||
detectLoginState?(): Promise<LoginDetectionResult>;
|
||||
|
||||
/**
|
||||
* Find a UI element on screen using image template matching.
|
||||
*
|
||||
* @param template - The image template to search for
|
||||
* @returns ElementLocation if found, null if not found
|
||||
*/
|
||||
findElement?(template: ImageTemplate): Promise<ElementLocation | null>;
|
||||
|
||||
/**
|
||||
* Bring the browser window to the foreground.
|
||||
* Searches for windows matching a title pattern (e.g., "iRacing").
|
||||
*
|
||||
* @param titlePattern - Optional window title pattern to match
|
||||
* @returns WindowFocusResult indicating success/failure
|
||||
*/
|
||||
focusBrowserWindow?(titlePattern?: string): Promise<WindowFocusResult>;
|
||||
|
||||
/**
|
||||
* Capture a region of the screen.
|
||||
*
|
||||
* @param region - Optional region to capture (full screen if omitted)
|
||||
* @returns ScreenCaptureResult with image data
|
||||
*/
|
||||
captureScreen?(region?: ScreenRegion): Promise<ScreenCaptureResult>;
|
||||
|
||||
/**
|
||||
* Click on a found element location.
|
||||
*
|
||||
* @param location - The element location from findElement
|
||||
* @returns ClickResult indicating success/failure
|
||||
*/
|
||||
clickAtLocation?(location: ElementLocation): Promise<ClickResult>;
|
||||
|
||||
/**
|
||||
* Wait for a template to appear on screen.
|
||||
*
|
||||
* @param template - The image template to wait for
|
||||
* @param maxWaitMs - Maximum time to wait in milliseconds
|
||||
* @returns WaitResult with timing information
|
||||
*/
|
||||
waitForTemplate?(template: ImageTemplate, maxWaitMs?: number): Promise<WaitResult>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type alias for backward compatibility.
|
||||
* IBrowserAutomation is now a subset of IScreenAutomation.
|
||||
* @deprecated Use IBrowserAutomation directly. IScreenAutomation was for OS-level
|
||||
* automation which has been removed in favor of browser-only automation.
|
||||
*/
|
||||
export type IBrowserAutomation = Pick<
|
||||
IScreenAutomation,
|
||||
| 'navigateToPage'
|
||||
| 'fillFormField'
|
||||
| 'clickElement'
|
||||
| 'waitForElement'
|
||||
| 'handleModal'
|
||||
| 'executeStep'
|
||||
| 'connect'
|
||||
| 'disconnect'
|
||||
| 'isConnected'
|
||||
>;
|
||||
export type IScreenAutomation = IBrowserAutomation;
|
||||
22
packages/application/use-cases/CheckAuthenticationUseCase.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { AuthenticationState } from '../../domain/value-objects/AuthenticationState';
|
||||
import { Result } from '../../shared/result/Result';
|
||||
import type { IAuthenticationService } from '../ports/IAuthenticationService';
|
||||
|
||||
/**
|
||||
* Use case for checking if the user has a valid iRacing session.
|
||||
*
|
||||
* This validates the session before automation starts, allowing
|
||||
* the system to prompt for re-authentication if needed.
|
||||
*/
|
||||
export class CheckAuthenticationUseCase {
|
||||
constructor(private readonly authService: IAuthenticationService) {}
|
||||
|
||||
/**
|
||||
* Execute the authentication check.
|
||||
*
|
||||
* @returns Result containing the current AuthenticationState
|
||||
*/
|
||||
async execute(): Promise<Result<AuthenticationState>> {
|
||||
return this.authService.checkSession();
|
||||
}
|
||||
}
|
||||
21
packages/application/use-cases/ClearSessionUseCase.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Result } from '../../shared/result/Result';
|
||||
import type { IAuthenticationService } from '../ports/IAuthenticationService';
|
||||
|
||||
/**
|
||||
* Use case for clearing the user's session (logout).
|
||||
*
|
||||
* Removes stored browser context and cookies, effectively logging
|
||||
* the user out. The next automation attempt will require re-authentication.
|
||||
*/
|
||||
export class ClearSessionUseCase {
|
||||
constructor(private readonly authService: IAuthenticationService) {}
|
||||
|
||||
/**
|
||||
* Execute the session clearing.
|
||||
*
|
||||
* @returns Result indicating success or failure
|
||||
*/
|
||||
async execute(): Promise<Result<void>> {
|
||||
return this.authService.clearSession();
|
||||
}
|
||||
}
|
||||
23
packages/application/use-cases/InitiateLoginUseCase.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Result } from '../../shared/result/Result';
|
||||
import type { IAuthenticationService } from '../ports/IAuthenticationService';
|
||||
|
||||
/**
|
||||
* Use case for initiating the manual login flow.
|
||||
*
|
||||
* Opens a visible browser window where the user can log into iRacing directly.
|
||||
* GridPilot never sees the credentials - it only waits for the URL to change
|
||||
* indicating successful login.
|
||||
*/
|
||||
export class InitiateLoginUseCase {
|
||||
constructor(private readonly authService: IAuthenticationService) {}
|
||||
|
||||
/**
|
||||
* Execute the login flow.
|
||||
* Opens browser and waits for user to complete manual login.
|
||||
*
|
||||
* @returns Result indicating success (login complete) or failure (cancelled/timeout)
|
||||
*/
|
||||
async execute(): Promise<Result<void>> {
|
||||
return this.authService.initiateLogin();
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,10 @@ export interface HostedSessionConfig {
|
||||
maxDrivers: number;
|
||||
trackId: string;
|
||||
carIds: string[];
|
||||
/** Search term for car selection (alternative to carIds) */
|
||||
carSearch?: string;
|
||||
/** Search term for track selection (alternative to trackId) */
|
||||
trackSearch?: string;
|
||||
weatherType: 'static' | 'dynamic';
|
||||
timeOfDay: 'morning' | 'afternoon' | 'evening' | 'night';
|
||||
sessionDuration: number;
|
||||
|
||||
@@ -23,8 +23,7 @@ const STEP_DESCRIPTIONS: Record<number, string> = {
|
||||
14: 'Set Time of Day',
|
||||
15: 'Configure Weather',
|
||||
16: 'Set Race Options',
|
||||
17: 'Configure Team Driving',
|
||||
18: 'Track Conditions (STOP - Manual Submit Required)',
|
||||
17: 'Track Conditions (STOP - Manual Submit Required)',
|
||||
};
|
||||
|
||||
export class StepTransitionValidator {
|
||||
|
||||
18
packages/domain/value-objects/AuthenticationState.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Value object representing the user's authentication state with iRacing.
|
||||
*
|
||||
* This is used to track whether the user has a valid session for automation
|
||||
* without GridPilot ever seeing or storing credentials (zero-knowledge design).
|
||||
*/
|
||||
export const AuthenticationState = {
|
||||
/** Authentication status has not yet been checked */
|
||||
UNKNOWN: 'UNKNOWN',
|
||||
/** Valid session exists and is ready for automation */
|
||||
AUTHENTICATED: 'AUTHENTICATED',
|
||||
/** Session was valid but has expired, re-authentication required */
|
||||
EXPIRED: 'EXPIRED',
|
||||
/** User explicitly logged out, clearing the session */
|
||||
LOGGED_OUT: 'LOGGED_OUT',
|
||||
} as const;
|
||||
|
||||
export type AuthenticationState = typeof AuthenticationState[keyof typeof AuthenticationState];
|
||||
@@ -93,4 +93,6 @@ export const DEFAULT_CONFIDENCE = {
|
||||
LOW: 0.8,
|
||||
/** Minimum acceptable confidence */
|
||||
MINIMUM: 0.7,
|
||||
/** Very low confidence for testing/debugging template matching issues */
|
||||
DEBUG: 0.5,
|
||||
} as const;
|
||||
@@ -9,8 +9,8 @@ export class StepId {
|
||||
if (!Number.isInteger(value)) {
|
||||
throw new Error('StepId must be an integer');
|
||||
}
|
||||
if (value < 1 || value > 18) {
|
||||
throw new Error('StepId must be between 1 and 18');
|
||||
if (value < 1 || value > 17) {
|
||||
throw new Error('StepId must be between 1 and 17');
|
||||
}
|
||||
return new StepId(value);
|
||||
}
|
||||
@@ -28,7 +28,7 @@ export class StepId {
|
||||
}
|
||||
|
||||
isFinalStep(): boolean {
|
||||
return this._value === 18;
|
||||
return this._value === 17;
|
||||
}
|
||||
|
||||
next(): StepId {
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import { IAutomationEngine, ValidationResult } from '../../../application/ports/IAutomationEngine';
|
||||
import { HostedSessionConfig } from '../../../domain/entities/HostedSessionConfig';
|
||||
import { StepId } from '../../../domain/value-objects/StepId';
|
||||
import type { IBrowserAutomation } from '../../../application/ports/IScreenAutomation';
|
||||
import { ISessionRepository } from '../../../application/ports/ISessionRepository';
|
||||
import { getStepName } from './templates/IRacingTemplateMap';
|
||||
|
||||
/**
|
||||
* Real Automation Engine Adapter.
|
||||
*
|
||||
* Orchestrates the automation workflow by:
|
||||
* 1. Validating session configuration
|
||||
* 2. Executing each step using real browser automation
|
||||
* 3. Managing session state transitions
|
||||
*
|
||||
* This is a REAL implementation that uses actual automation,
|
||||
* not a mock. Currently delegates to deprecated nut.js adapters for
|
||||
* screen automation operations.
|
||||
*
|
||||
* @deprecated This adapter currently delegates to the deprecated NutJsAutomationAdapter.
|
||||
* Should be updated to use Playwright browser automation when available.
|
||||
* See docs/ARCHITECTURE.md for the updated automation strategy.
|
||||
*/
|
||||
export class AutomationEngineAdapter implements IAutomationEngine {
|
||||
private isRunning = false;
|
||||
private automationPromise: Promise<void> | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly browserAutomation: IBrowserAutomation,
|
||||
private readonly sessionRepository: ISessionRepository
|
||||
) {}
|
||||
|
||||
async validateConfiguration(config: HostedSessionConfig): Promise<ValidationResult> {
|
||||
if (!config.sessionName || config.sessionName.trim() === '') {
|
||||
return { isValid: false, error: 'Session name is required' };
|
||||
}
|
||||
if (!config.trackId || config.trackId.trim() === '') {
|
||||
return { isValid: false, error: 'Track ID is required' };
|
||||
}
|
||||
if (!config.carIds || config.carIds.length === 0) {
|
||||
return { isValid: false, error: 'At least one car must be selected' };
|
||||
}
|
||||
return { isValid: true };
|
||||
}
|
||||
|
||||
async executeStep(stepId: StepId, config: HostedSessionConfig): Promise<void> {
|
||||
const sessions = await this.sessionRepository.findAll();
|
||||
const session = sessions[0];
|
||||
if (!session) {
|
||||
throw new Error('No active session found');
|
||||
}
|
||||
|
||||
// Start session if it's at step 1 and pending
|
||||
if (session.state.isPending() && stepId.value === 1) {
|
||||
session.start();
|
||||
await this.sessionRepository.update(session);
|
||||
|
||||
// Start automated progression
|
||||
this.startAutomation(config);
|
||||
}
|
||||
}
|
||||
|
||||
private startAutomation(config: HostedSessionConfig): void {
|
||||
if (this.isRunning) {
|
||||
return;
|
||||
}
|
||||
this.isRunning = true;
|
||||
this.automationPromise = this.runAutomationLoop(config);
|
||||
}
|
||||
|
||||
private async runAutomationLoop(config: HostedSessionConfig): Promise<void> {
|
||||
while (this.isRunning) {
|
||||
try {
|
||||
const sessions = await this.sessionRepository.findAll();
|
||||
const session = sessions[0];
|
||||
|
||||
if (!session || !session.state.isInProgress()) {
|
||||
this.isRunning = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const currentStep = session.currentStep;
|
||||
|
||||
// Execute current step using the browser automation
|
||||
if (this.browserAutomation.executeStep) {
|
||||
// Use real workflow automation with IRacingSelectorMap
|
||||
const result = await this.browserAutomation.executeStep(currentStep, config as unknown as Record<string, unknown>);
|
||||
if (!result.success) {
|
||||
const errorMessage = `Step ${currentStep.value} (${getStepName(currentStep.value)}) failed: ${result.error}`;
|
||||
console.error(errorMessage);
|
||||
|
||||
// Stop automation and mark session as failed
|
||||
this.isRunning = false;
|
||||
|
||||
session.fail(errorMessage);
|
||||
await this.sessionRepository.update(session);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// Fallback for adapters without executeStep
|
||||
await this.browserAutomation.navigateToPage(`step-${currentStep.value}`);
|
||||
}
|
||||
|
||||
// Transition to next step
|
||||
if (!currentStep.isFinalStep()) {
|
||||
session.transitionToStep(currentStep.next());
|
||||
await this.sessionRepository.update(session);
|
||||
|
||||
// If we just transitioned to the final step, execute it before stopping
|
||||
const nextStep = session.currentStep;
|
||||
if (nextStep.isFinalStep()) {
|
||||
// Execute final step handler
|
||||
if (this.browserAutomation.executeStep) {
|
||||
const result = await this.browserAutomation.executeStep(nextStep, config as unknown as Record<string, unknown>);
|
||||
if (!result.success) {
|
||||
const errorMessage = `Step ${nextStep.value} (${getStepName(nextStep.value)}) failed: ${result.error}`;
|
||||
console.error(errorMessage);
|
||||
// Don't try to fail terminal session - just log the error
|
||||
// Session is already in STOPPED_AT_STEP_18 state after transitionToStep()
|
||||
}
|
||||
}
|
||||
// Stop after final step
|
||||
this.isRunning = false;
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// Current step is already final - stop
|
||||
this.isRunning = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Wait before next iteration
|
||||
await this.delay(500);
|
||||
} catch (error) {
|
||||
console.error('Automation error:', error);
|
||||
this.isRunning = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private delay(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
public stopAutomation(): void {
|
||||
this.isRunning = false;
|
||||
this.automationPromise = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
import { spawn, ChildProcess } from 'child_process';
|
||||
import * as path from 'path';
|
||||
import type { IFixtureServer } from './FixtureServer';
|
||||
|
||||
/**
|
||||
* Browser window configuration for E2E tests.
|
||||
*/
|
||||
export interface BrowserWindowConfig {
|
||||
/** X position of the window (default: 0) */
|
||||
x: number;
|
||||
/** Y position of the window (default: 0) */
|
||||
y: number;
|
||||
/** Window width (default: 1920) */
|
||||
width: number;
|
||||
/** Window height (default: 1080) */
|
||||
height: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of browser launch operation.
|
||||
*/
|
||||
export interface BrowserLaunchResult {
|
||||
success: boolean;
|
||||
pid?: number;
|
||||
url?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* E2E Test Browser Launcher.
|
||||
*
|
||||
* Launches a real Chrome browser window for E2E testing with nut.js automation.
|
||||
* The browser displays HTML fixtures served by FixtureServer and is positioned
|
||||
* at a fixed location for deterministic template matching.
|
||||
*
|
||||
* IMPORTANT: This creates a REAL browser window on the user's screen.
|
||||
* It requires:
|
||||
* - Chrome/Chromium installed
|
||||
* - Display available (not headless)
|
||||
* - macOS permissions granted
|
||||
*/
|
||||
export class E2ETestBrowserLauncher {
|
||||
private browserProcess: ChildProcess | null = null;
|
||||
private windowConfig: BrowserWindowConfig;
|
||||
|
||||
constructor(
|
||||
private fixtureServer: IFixtureServer,
|
||||
windowConfig?: Partial<BrowserWindowConfig>
|
||||
) {
|
||||
this.windowConfig = {
|
||||
x: windowConfig?.x ?? 0,
|
||||
y: windowConfig?.y ?? 0,
|
||||
width: windowConfig?.width ?? 1920,
|
||||
height: windowConfig?.height ?? 1080,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Launch Chrome browser pointing to the fixture server.
|
||||
*
|
||||
* @param initialFixtureStep - Optional step number to navigate to initially
|
||||
* @returns BrowserLaunchResult indicating success or failure
|
||||
*/
|
||||
async launch(initialFixtureStep?: number): Promise<BrowserLaunchResult> {
|
||||
if (this.browserProcess) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Browser already launched. Call close() first.',
|
||||
};
|
||||
}
|
||||
|
||||
if (!this.fixtureServer.isRunning()) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Fixture server is not running. Start it before launching browser.',
|
||||
};
|
||||
}
|
||||
|
||||
const url = initialFixtureStep
|
||||
? this.fixtureServer.getFixtureUrl(initialFixtureStep)
|
||||
: `${this.getBaseUrl()}/all-steps.html`;
|
||||
|
||||
const chromePath = this.findChromePath();
|
||||
if (!chromePath) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Chrome/Chromium not found. Please install Chrome browser.',
|
||||
};
|
||||
}
|
||||
|
||||
const args = this.buildChromeArgs(url);
|
||||
|
||||
try {
|
||||
this.browserProcess = spawn(chromePath, args, {
|
||||
detached: false,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
// Give browser time to start
|
||||
await this.waitForBrowserStart();
|
||||
|
||||
if (this.browserProcess.pid) {
|
||||
return {
|
||||
success: true,
|
||||
pid: this.browserProcess.pid,
|
||||
url,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Browser process started but no PID available',
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Failed to launch browser: ${error}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate the browser to a specific fixture step.
|
||||
*/
|
||||
async navigateToStep(stepNumber: number): Promise<void> {
|
||||
// Note: This would require browser automation to navigate
|
||||
// For now, we'll log the intent - actual navigation happens via nut.js
|
||||
const url = this.fixtureServer.getFixtureUrl(stepNumber);
|
||||
console.log(`[E2ETestBrowserLauncher] Navigate to step ${stepNumber}: ${url}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the browser process.
|
||||
*/
|
||||
async close(): Promise<void> {
|
||||
if (!this.browserProcess) {
|
||||
return;
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
if (!this.browserProcess) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
// Set up listener for process exit
|
||||
this.browserProcess.once('exit', () => {
|
||||
this.browserProcess = null;
|
||||
resolve();
|
||||
});
|
||||
|
||||
// Try graceful termination first
|
||||
this.browserProcess.kill('SIGTERM');
|
||||
|
||||
// Force kill after timeout
|
||||
setTimeout(() => {
|
||||
if (this.browserProcess) {
|
||||
this.browserProcess.kill('SIGKILL');
|
||||
this.browserProcess = null;
|
||||
resolve();
|
||||
}
|
||||
}, 3000);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if browser is running.
|
||||
*/
|
||||
isRunning(): boolean {
|
||||
return this.browserProcess !== null && !this.browserProcess.killed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the browser process PID.
|
||||
*/
|
||||
getPid(): number | undefined {
|
||||
return this.browserProcess?.pid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the base URL of the fixture server.
|
||||
*/
|
||||
private getBaseUrl(): string {
|
||||
// Extract from fixture server
|
||||
return `http://localhost:3456`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find Chrome/Chromium executable path.
|
||||
*/
|
||||
private findChromePath(): string | null {
|
||||
const platform = process.platform;
|
||||
|
||||
const paths: string[] = [];
|
||||
|
||||
if (platform === 'darwin') {
|
||||
paths.push(
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
||||
'/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
|
||||
);
|
||||
} else if (platform === 'linux') {
|
||||
paths.push(
|
||||
'/usr/bin/google-chrome',
|
||||
'/usr/bin/google-chrome-stable',
|
||||
'/usr/bin/chromium',
|
||||
'/usr/bin/chromium-browser',
|
||||
'/snap/bin/chromium',
|
||||
);
|
||||
} else if (platform === 'win32') {
|
||||
const programFiles = process.env['PROGRAMFILES'] || 'C:\\Program Files';
|
||||
const programFilesX86 = process.env['PROGRAMFILES(X86)'] || 'C:\\Program Files (x86)';
|
||||
const localAppData = process.env['LOCALAPPDATA'] || '';
|
||||
|
||||
paths.push(
|
||||
path.join(programFiles, 'Google', 'Chrome', 'Application', 'chrome.exe'),
|
||||
path.join(programFilesX86, 'Google', 'Chrome', 'Application', 'chrome.exe'),
|
||||
path.join(localAppData, 'Google', 'Chrome', 'Application', 'chrome.exe'),
|
||||
);
|
||||
}
|
||||
|
||||
// Check if any path exists
|
||||
const fs = require('fs');
|
||||
for (const chromePath of paths) {
|
||||
try {
|
||||
if (fs.existsSync(chromePath)) {
|
||||
return chromePath;
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build Chrome command line arguments.
|
||||
*/
|
||||
private buildChromeArgs(url: string): string[] {
|
||||
const { x, y, width, height } = this.windowConfig;
|
||||
|
||||
return [
|
||||
// Disable various Chrome features for cleaner automation
|
||||
'--disable-extensions',
|
||||
'--disable-plugins',
|
||||
'--disable-sync',
|
||||
'--disable-translate',
|
||||
'--disable-background-networking',
|
||||
'--disable-default-apps',
|
||||
'--disable-hang-monitor',
|
||||
'--disable-popup-blocking',
|
||||
'--disable-prompt-on-repost',
|
||||
'--disable-client-side-phishing-detection',
|
||||
'--disable-component-update',
|
||||
|
||||
// Window positioning
|
||||
`--window-position=${x},${y}`,
|
||||
`--window-size=${width},${height}`,
|
||||
|
||||
// Start with specific window settings
|
||||
'--start-maximized=false',
|
||||
'--no-first-run',
|
||||
'--no-default-browser-check',
|
||||
|
||||
// Disable GPU for more consistent rendering in automation
|
||||
'--disable-gpu',
|
||||
|
||||
// Open DevTools disabled for cleaner screenshots
|
||||
// '--auto-open-devtools-for-tabs',
|
||||
|
||||
// Start with the URL
|
||||
url,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for browser to start and window to be ready.
|
||||
*/
|
||||
private async waitForBrowserStart(): Promise<void> {
|
||||
// Give Chrome time to:
|
||||
// 1. Start the process
|
||||
// 2. Create the window
|
||||
// 3. Load the initial page
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory function to create a browser launcher with default settings.
|
||||
*/
|
||||
export function createE2EBrowserLauncher(
|
||||
fixtureServer: IFixtureServer,
|
||||
config?: Partial<BrowserWindowConfig>
|
||||
): E2ETestBrowserLauncher {
|
||||
return new E2ETestBrowserLauncher(fixtureServer, config);
|
||||
}
|
||||
157
packages/infrastructure/adapters/automation/FixtureServer.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import * as http from 'http';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
export interface IFixtureServer {
|
||||
start(port?: number): Promise<{ url: string; port: number }>;
|
||||
stop(): Promise<void>;
|
||||
getFixtureUrl(stepNumber: number): string;
|
||||
isRunning(): boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Step number to fixture file mapping.
|
||||
* Steps 2-18 map to the corresponding HTML fixture files.
|
||||
*/
|
||||
const STEP_TO_FIXTURE: Record<number, string> = {
|
||||
2: 'step-02-hosted-racing.html',
|
||||
3: 'step-03-create-race.html',
|
||||
4: 'step-04-race-information.html',
|
||||
5: 'step-05-server-details.html',
|
||||
6: 'step-06-set-admins.html',
|
||||
7: 'step-07-add-admin.html',
|
||||
8: 'step-08-time-limits.html',
|
||||
9: 'step-09-set-cars.html',
|
||||
10: 'step-10-add-car.html',
|
||||
11: 'step-11-set-car-classes.html',
|
||||
12: 'step-12-set-track.html',
|
||||
13: 'step-13-add-track.html',
|
||||
14: 'step-14-track-options.html',
|
||||
15: 'step-15-time-of-day.html',
|
||||
16: 'step-16-weather.html',
|
||||
17: 'step-17-race-options.html',
|
||||
18: 'step-18-track-conditions.html',
|
||||
};
|
||||
|
||||
export class FixtureServer implements IFixtureServer {
|
||||
private server: http.Server | null = null;
|
||||
private port: number = 3456;
|
||||
private fixturesPath: string;
|
||||
|
||||
constructor(fixturesPath?: string) {
|
||||
this.fixturesPath = fixturesPath ?? path.resolve(process.cwd(), 'resources/mock-fixtures');
|
||||
}
|
||||
|
||||
async start(port: number = 3456): Promise<{ url: string; port: number }> {
|
||||
if (this.server) {
|
||||
return { url: `http://localhost:${this.port}`, port: this.port };
|
||||
}
|
||||
|
||||
this.port = port;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.server = http.createServer((req, res) => {
|
||||
this.handleRequest(req, res);
|
||||
});
|
||||
|
||||
this.server.on('error', (err: NodeJS.ErrnoException) => {
|
||||
if (err.code === 'EADDRINUSE') {
|
||||
// Try next port
|
||||
this.server = null;
|
||||
this.start(port + 1).then(resolve).catch(reject);
|
||||
} else {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
|
||||
this.server.listen(this.port, () => {
|
||||
resolve({ url: `http://localhost:${this.port}`, port: this.port });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
if (!this.server) {
|
||||
return;
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.server!.close((err) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
this.server = null;
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
getFixtureUrl(stepNumber: number): string {
|
||||
const fixture = STEP_TO_FIXTURE[stepNumber];
|
||||
if (!fixture) {
|
||||
return `http://localhost:${this.port}/`;
|
||||
}
|
||||
return `http://localhost:${this.port}/${fixture}`;
|
||||
}
|
||||
|
||||
isRunning(): boolean {
|
||||
return this.server !== null;
|
||||
}
|
||||
|
||||
private handleRequest(req: http.IncomingMessage, res: http.ServerResponse): void {
|
||||
const urlPath = req.url || '/';
|
||||
const fileName = urlPath === '/' ? 'step-02-hosted-racing.html' : urlPath.replace(/^\//, '');
|
||||
const filePath = path.join(this.fixturesPath, fileName);
|
||||
|
||||
// Security check - prevent directory traversal
|
||||
if (!filePath.startsWith(this.fixturesPath)) {
|
||||
res.writeHead(403, { 'Content-Type': 'text/plain' });
|
||||
res.end('Forbidden');
|
||||
return;
|
||||
}
|
||||
|
||||
fs.readFile(filePath, (err, data) => {
|
||||
if (err) {
|
||||
if (err.code === 'ENOENT') {
|
||||
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
||||
res.end('Not Found');
|
||||
} else {
|
||||
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
||||
res.end('Internal Server Error');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
const contentTypes: Record<string, string> = {
|
||||
'.html': 'text/html',
|
||||
'.css': 'text/css',
|
||||
'.js': 'application/javascript',
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.gif': 'image/gif',
|
||||
'.svg': 'image/svg+xml',
|
||||
};
|
||||
|
||||
const contentType = contentTypes[ext] || 'application/octet-stream';
|
||||
res.writeHead(200, { 'Content-Type': contentType });
|
||||
res.end(data);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the fixture filename for a given step number.
|
||||
*/
|
||||
export function getFixtureForStep(stepNumber: number): string | undefined {
|
||||
return STEP_TO_FIXTURE[stepNumber];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all step-to-fixture mappings.
|
||||
*/
|
||||
export function getAllStepFixtureMappings(): Record<number, string> {
|
||||
return { ...STEP_TO_FIXTURE };
|
||||
}
|
||||
210
packages/infrastructure/adapters/automation/IRacingSelectors.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* Selectors for the real iRacing website (members.iracing.com)
|
||||
* Uses text-based and ARIA selectors since the site uses React/Chakra UI
|
||||
* with dynamically generated class names.
|
||||
*
|
||||
* VERIFIED against real iRacing HTML captured 2024-11-23
|
||||
*/
|
||||
export const IRACING_SELECTORS = {
|
||||
// Login page
|
||||
login: {
|
||||
emailInput: '#username, input[name="username"], input[type="email"]',
|
||||
passwordInput: '#password, input[type="password"]',
|
||||
submitButton: 'button[type="submit"], button:has-text("Sign In")',
|
||||
},
|
||||
|
||||
// Hosted Racing page (Step 2)
|
||||
hostedRacing: {
|
||||
// Main "Create a Race" button on the hosted sessions page
|
||||
createRaceButton: 'button:has-text("Create a Race"), button[aria-label="Create a Race"]',
|
||||
hostedTab: '[aria-label*="Hosted" i], [role="tab"]:has-text("Hosted")',
|
||||
// Modal that appears after clicking "Create a Race"
|
||||
createRaceModal: '#confirm-create-race-modal, .modal:has-text("Create a Race")',
|
||||
// "New Race" button in the modal body (not footer) - two side-by-side buttons in a row
|
||||
// Verified from real iRacing HTML: buttons are <a class="btn btn-lg btn-info btn-block"> in modal-body
|
||||
newRaceButton: '#confirm-create-race-modal .modal-body a.btn:has-text("New Race"), #confirm-create-race-modal a.btn:has(.icon-wand)',
|
||||
lastSettingsButton: '#confirm-create-race-modal .modal-body a.btn:has-text("Last Settings"), #confirm-create-race-modal a.btn:has(.icon-servers)',
|
||||
},
|
||||
|
||||
// Common modal/wizard selectors - VERIFIED from real HTML
|
||||
wizard: {
|
||||
modal: '#create-race-modal, [role="dialog"], .modal.fade.in',
|
||||
modalDialog: '#create-race-modal-modal-dialog, .modal-dialog',
|
||||
modalContent: '#create-race-modal-modal-content, .modal-content',
|
||||
modalTitle: '[data-testid="modal-title"], .modal-title',
|
||||
// Wizard footer buttons - these are anchor tags styled as buttons
|
||||
// The "Next" button shows the name of the next step (e.g., "Server Details")
|
||||
nextButton: '.wizard-footer a.btn:not(.disabled):has(.icon-caret-right)',
|
||||
backButton: '.wizard-footer a.btn:has(.icon-caret-left):has-text("Back")',
|
||||
// Modal footer actions
|
||||
confirmButton: '.modal-footer a.btn-success, button:has-text("Confirm"), button:has-text("OK")',
|
||||
cancelButton: '.modal-footer a.btn-secondary:has-text("Back"), button:has-text("Cancel")',
|
||||
closeButton: '.modal-header a.close, [aria-label="Close"]',
|
||||
// Wizard sidebar navigation links - VERIFIED IDs from real HTML
|
||||
sidebarLinks: {
|
||||
raceInformation: '#wizard-sidebar-link-set-session-information',
|
||||
serverDetails: '#wizard-sidebar-link-set-server-details',
|
||||
admins: '#wizard-sidebar-link-set-admins',
|
||||
timeLimit: '#wizard-sidebar-link-set-time-limit',
|
||||
cars: '#wizard-sidebar-link-set-cars',
|
||||
track: '#wizard-sidebar-link-set-track',
|
||||
trackOptions: '#wizard-sidebar-link-set-track-options',
|
||||
timeOfDay: '#wizard-sidebar-link-set-time-of-day',
|
||||
weather: '#wizard-sidebar-link-set-weather',
|
||||
raceOptions: '#wizard-sidebar-link-set-race-options',
|
||||
trackConditions: '#wizard-sidebar-link-set-track-conditions',
|
||||
},
|
||||
// Wizard step containers (the visible step content)
|
||||
stepContainers: {
|
||||
raceInformation: '#set-session-information',
|
||||
serverDetails: '#set-server-details',
|
||||
admins: '#set-admins',
|
||||
timeLimit: '#set-time-limit',
|
||||
cars: '#set-cars',
|
||||
track: '#set-track',
|
||||
trackOptions: '#set-track-options',
|
||||
timeOfDay: '#set-time-of-day',
|
||||
weather: '#set-weather',
|
||||
raceOptions: '#set-race-options',
|
||||
trackConditions: '#set-track-conditions',
|
||||
},
|
||||
},
|
||||
|
||||
// Form fields - based on actual iRacing DOM structure
|
||||
fields: {
|
||||
textInput: 'input.form-control, .chakra-input, input[type="text"]',
|
||||
passwordInput: 'input[type="password"], input[maxlength="32"].form-control',
|
||||
textarea: 'textarea.form-control, .chakra-textarea, textarea',
|
||||
select: '.chakra-select, select.form-control, select',
|
||||
checkbox: '.chakra-checkbox, input[type="checkbox"], .switch-checkbox',
|
||||
slider: '.chakra-slider, input[type="range"]',
|
||||
toggle: '.switch input.switch-checkbox, .toggle-switch input',
|
||||
},
|
||||
|
||||
// Step-specific selectors - VERIFIED from real iRacing HTML structure
|
||||
steps: {
|
||||
// Step 3: Race Information - form structure inside #set-session-information
|
||||
// Form groups have labels followed by inputs
|
||||
sessionName: '#set-session-information .card-block .form-group:first-of-type input.form-control',
|
||||
sessionNameAlt: '#set-session-information input.form-control[type="text"]:not([maxlength])',
|
||||
password: '#set-session-information .card-block .form-group:nth-of-type(2) input.form-control',
|
||||
passwordAlt: '#set-session-information input.form-control[maxlength="32"]',
|
||||
description: '#set-session-information .card-block .form-group:last-of-type textarea.form-control',
|
||||
descriptionAlt: '#set-session-information textarea.form-control',
|
||||
// League racing toggle in Step 3
|
||||
leagueRacingToggle: '#set-session-information .switch-checkbox',
|
||||
|
||||
// Step 4: Server Details
|
||||
region: '#set-server-details select.form-control, #set-server-details [data-dropdown="region"]',
|
||||
startNow: '#set-server-details .switch-checkbox, #set-server-details input[type="checkbox"]',
|
||||
|
||||
// Step 5/6: Admins
|
||||
adminSearch: '.wizard-sidebar input[placeholder*="Search"], #set-admins input[placeholder*="Search"]',
|
||||
adminList: '#set-admins [data-list="admins"]',
|
||||
|
||||
// Step 7: Time Limits - Bootstrap-slider uses hidden input[type="text"] with id containing slider name
|
||||
// Also targets the visible slider handle for interaction
|
||||
practice: '#set-time-limit input[id*="practice"], #set-time-limit .slider input[type="text"], #set-time-limit [data-slider="practice"]',
|
||||
qualify: '#set-time-limit input[id*="qualify"], #set-time-limit .slider input[type="text"], #set-time-limit [data-slider="qualify"]',
|
||||
race: '#set-time-limit input[id*="race"], #set-time-limit .slider input[type="text"], #set-time-limit [data-slider="race"]',
|
||||
|
||||
// Step 8/9: Cars
|
||||
carSearch: '.wizard-sidebar input[placeholder*="Search"], #set-cars input[placeholder*="Search"], .modal input[placeholder*="Search"]',
|
||||
carList: '#set-cars [data-list="cars"]',
|
||||
// Add Car button - triggers the Add Car modal
|
||||
addCarButton: '#set-cars a.btn:has(.icon-plus), #set-cars button:has-text("Add"), #set-cars a.btn:has-text("Add")',
|
||||
// Add Car modal - appears after clicking Add Car button
|
||||
addCarModal: '#add-car-modal, .modal:has(input[placeholder*="Search"]):has-text("Car")',
|
||||
// Select button inside Add Car modal table row - clicking this adds the car immediately (no confirm step)
|
||||
// The "Select" button is an anchor styled as: a.btn.btn-block.btn-primary.btn-xs
|
||||
carSelectButton: '.modal table .btn-primary:has-text("Select"), .modal .btn-primary.btn-xs:has-text("Select"), .modal tbody .btn-primary',
|
||||
|
||||
// Step 10/11/12: Track
|
||||
trackSearch: '.wizard-sidebar input[placeholder*="Search"], #set-track input[placeholder*="Search"], .modal input[placeholder*="Search"]',
|
||||
trackList: '#set-track [data-list="tracks"]',
|
||||
// Add Track button - triggers the Add Track modal
|
||||
addTrackButton: '#set-track a.btn:has(.icon-plus), #set-track button:has-text("Add"), #set-track a.btn:has-text("Add"), #set-track button:has-text("Select"), #set-track a.btn:has-text("Select")',
|
||||
// Add Track modal - appears after clicking Add Track button
|
||||
addTrackModal: '#add-track-modal, .modal:has(input[placeholder*="Search"]):has-text("Track")',
|
||||
// Select button inside Add Track modal table row - clicking this selects the track immediately (no confirm step)
|
||||
// Prefer direct buttons (not dropdown toggles) for single-config tracks
|
||||
trackSelectButton: '.modal table a.btn.btn-primary.btn-xs:not(.dropdown-toggle)',
|
||||
// Dropdown toggle for multi-config tracks - opens a menu of track configurations
|
||||
trackSelectDropdown: '.modal table a.btn.btn-primary.btn-xs.dropdown-toggle',
|
||||
// First item in the dropdown menu for selecting track configuration
|
||||
trackSelectDropdownItem: '.dropdown-menu.show .dropdown-item:first-child, .dropdown-menu-lg .dropdown-item:first-child',
|
||||
|
||||
// Step 13: Track Options
|
||||
trackConfig: '#set-track-options select.form-control, #set-track-options [data-dropdown="trackConfig"]',
|
||||
|
||||
// Step 14: Time of Day - iRacing uses datetime picker (rdt class) and Bootstrap-slider components
|
||||
// The datetime picker has input.form-control, sliders have hidden input[type="text"]
|
||||
timeOfDay: '#set-time-of-day .rdt input.form-control, #set-time-of-day input[id*="slider"], #set-time-of-day .slider input[type="text"], #set-time-of-day [data-slider="timeOfDay"]',
|
||||
|
||||
// Step 15: Weather
|
||||
weatherType: '#set-weather select.form-control, #set-weather [data-dropdown="weatherType"]',
|
||||
// Temperature slider uses Bootstrap-slider with hidden input[type="text"]
|
||||
temperature: '#set-weather input[id*="slider"], #set-weather .slider input[type="text"], #set-weather [data-slider="temperature"]',
|
||||
|
||||
// Step 16: Race Options
|
||||
maxDrivers: '#set-race-options input[name*="maxDrivers"], #set-race-options input[type="number"]',
|
||||
rollingStart: '#set-race-options .switch-checkbox[name*="rolling"], #set-race-options input[type="checkbox"]',
|
||||
|
||||
// Step 17: Track Conditions (final step)
|
||||
trackState: '#set-track-conditions select.form-control, #set-track-conditions [data-dropdown="trackState"]',
|
||||
},
|
||||
|
||||
/**
|
||||
* DANGER ZONE - Selectors for checkout/payment buttons that should NEVER be clicked.
|
||||
* The automation must block any click on these selectors to prevent accidental purchases.
|
||||
* VERIFIED from real iRacing HTML - the checkout button has class btn-success with icon-cart
|
||||
*/
|
||||
BLOCKED_SELECTORS: {
|
||||
// Checkout/payment buttons - NEVER click these (verified from real HTML)
|
||||
checkout: 'a.btn-success:has(.icon-cart), a.btn:has-text("Check Out"), button:has-text("Check Out"), [data-testid*="checkout"]',
|
||||
purchase: 'button:has-text("Purchase"), a.btn:has-text("Purchase"), .chakra-button:has-text("Purchase"), button[aria-label="Purchase"]',
|
||||
buy: 'button:has-text("Buy"), a.btn:has-text("Buy Now"), button:has-text("Buy Now")',
|
||||
payment: 'button[type="submit"]:has-text("Submit Payment"), .payment-button, #checkout-button, button:has-text("Pay"), a.btn:has-text("Pay")',
|
||||
cart: 'a.btn:has(.icon-cart), button:has(.icon-cart), .btn-success:has(.icon-cart)',
|
||||
// Price labels that indicate purchase actions (e.g., "$0.50")
|
||||
priceAction: 'a.btn:has(.label-pill:has-text("$")), button:has(.label-pill:has-text("$")), .btn:has(.label-inverse:has-text("$"))',
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Combined selector for all blocked/dangerous elements.
|
||||
* Use this to check if any selector targets a payment button.
|
||||
*/
|
||||
export const ALL_BLOCKED_SELECTORS = Object.values(IRACING_SELECTORS.BLOCKED_SELECTORS).join(', ');
|
||||
|
||||
/**
|
||||
* Keywords that indicate a dangerous/checkout action.
|
||||
* Used for text-based safety checks.
|
||||
*/
|
||||
export const BLOCKED_KEYWORDS = [
|
||||
'checkout',
|
||||
'check out',
|
||||
'purchase',
|
||||
'buy now',
|
||||
'buy',
|
||||
'pay',
|
||||
'submit payment',
|
||||
'add to cart',
|
||||
'proceed to payment',
|
||||
] as const;
|
||||
|
||||
export const IRACING_URLS = {
|
||||
hostedSessions: 'https://members-ng.iracing.com/web/racing/hosted/browse-sessions',
|
||||
login: 'https://members.iracing.com/membersite/login.jsp',
|
||||
home: 'https://members.iracing.com',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Timeout values for real iRacing automation (in milliseconds)
|
||||
*/
|
||||
export const IRACING_TIMEOUTS = {
|
||||
navigation: 30000,
|
||||
elementWait: 15000,
|
||||
loginWait: 120000, // 2 minutes for manual login
|
||||
pageLoad: 20000,
|
||||
} as const;
|
||||