finished up widgets

This commit is contained in:
Guy Sandler 2026-10-18 16:57:19 -07:00
parent 2687239ecb
commit 80662e75e8
9 changed files with 572 additions and 523 deletions

View File

@ -220,6 +220,7 @@ If nobody is working on the feature, you can clone the dev branch and make your
#### Fork Contributers #### Fork Contributers
- [TabulateJarl8](https://github.com/TabulateJarl8) - [TabulateJarl8](https://github.com/TabulateJarl8)
- [dhogenson](https://github.com/dhogenson) - [dhogenson](https://github.com/dhogenson)
- [Y2Kwastaken](https://github.com/Y2Kwastaken)
#### Original Owner #### Original Owner

View File

@ -146,18 +146,12 @@
"breathing_breaks": { "breathing_breaks": {
"message": "Breathing Breaks" "message": "Breathing Breaks"
}, },
"music": {
"message": "Music"
},
"daily_quotes": { "daily_quotes": {
"message": "Daily Quotes" "message": "Daily Quotes"
}, },
"widget_short_focus": { "widget_short_focus": {
"message": "Focus" "message": "Focus"
}, },
"widget_short_music": {
"message": "Music"
},
"widget_short_breathing": { "widget_short_breathing": {
"message": "Breathing" "message": "Breathing"
}, },

View File

@ -473,7 +473,7 @@
modal. */ modal. */
/* ===== GPA calculator modal (rebuilt) ===== */ /* ===== GPA calculator modal (rebuilt) ===== */
#canvasrefined-gpa-modal { #canvasrefined-gpa-modal {
--cr-gpa-course-cols: 30px minmax(0,1fr) 66px 58px 118px 16px minmax(88px,auto); --cr-gpa-course-cols: 30px minmax(0,1fr) 80px 58px 118px 16px minmax(88px,auto);
--cr-gpa-accent: var(--ic-brand-primary,#0374b5); --cr-gpa-accent: var(--ic-brand-primary,#0374b5);
position:fixed;inset:0;z-index:2147483100;background:rgba(15,25,40,.5);backdrop-filter:blur(3px); position:fixed;inset:0;z-index:2147483100;background:rgba(15,25,40,.5);backdrop-filter:blur(3px);
display:flex;align-items:flex-start;justify-content:center;padding:7vh 16px 16px; display:flex;align-items:flex-start;justify-content:center;padding:7vh 16px 16px;
@ -544,12 +544,12 @@
.cr-gpa-suffix {flex:none;padding:0 8px 0 2px;font-size:11px;color:var(--bctext-2,#9aa3ab);} .cr-gpa-suffix {flex:none;padding:0 8px 0 2px;font-size:11px;color:var(--bctext-2,#9aa3ab);}
.cr-gpa-note {color:var(--bctext-2,#9aa3ab);font-size:12px;line-height:1.5;margin:14px 0 0;padding-top:12px;border-top:1px solid var(--bcborders,#c7cdd1);} .cr-gpa-note {color:var(--bctext-2,#9aa3ab);font-size:12px;line-height:1.5;margin:14px 0 0;padding-top:12px;border-top:1px solid var(--bcborders,#c7cdd1);}
@media (max-width:720px) { @media (max-width:720px) {
#canvasrefined-gpa-modal {--cr-gpa-course-cols:26px minmax(0,1fr) 58px 52px 108px 15px 88px;padding:4vh 8px 8px;} #canvasrefined-gpa-modal {--cr-gpa-course-cols:26px minmax(0,1fr) 72px 52px 108px 15px 88px;padding:4vh 8px 8px;}
.cr-gpa-body {grid-template-columns:1fr;} .cr-gpa-body {grid-template-columns:1fr;}
.cr-gpa-pane-scale {border-left:none;border-top:1px solid var(--bcborders,#c7cdd1);} .cr-gpa-pane-scale {border-left:none;border-top:1px solid var(--bcborders,#c7cdd1);}
} }
/* ===== Generic dashboard widgets: Focus Timer, Music, Breathing, Quotes ===== /* ===== Generic dashboard widgets: Focus Timer, Breathing, Quotes =====
Shared shell follows the same card look as Notes/GPA: 262px grid cell with Shared shell follows the same card look as Notes/GPA: 262px grid cell with
the 36px Canvas gutter when placed before/after the course cards, and a the 36px Canvas gutter when placed before/after the course cards, and a
full-width flowing card when placed in the top/bottom container row. */ full-width flowing card when placed in the top/bottom container row. */
@ -575,6 +575,7 @@
width: 100%; width: 100%;
height: 100%; height: 100%;
margin: 0; margin: 0;
min-height: 0;
} }
.canvasrefined-widget-slot .canvasrefined-widget-card {width:100%;margin:0;height:100%;} .canvasrefined-widget-slot .canvasrefined-widget-card {width:100%;margin:0;height:100%;}
.cr-widget-head {display:flex;align-items:center;justify-content:space-between;padding:12px 14px 10px;border-bottom:1px solid var(--bcborders,#c7cdd1);} .cr-widget-head {display:flex;align-items:center;justify-content:space-between;padding:12px 14px 10px;border-bottom:1px solid var(--bcborders,#c7cdd1);}
@ -595,14 +596,14 @@
.cr-timer-preset.active {background:color-mix(in srgb, var(--bclinks,#0374b5) 18%, transparent);border-color:var(--bclinks,#0374b5);color:var(--bclinks,#0374b5);} .cr-timer-preset.active {background:color-mix(in srgb, var(--bclinks,#0374b5) 18%, transparent);border-color:var(--bclinks,#0374b5);color:var(--bclinks,#0374b5);}
.cr-timer-custom-row {display:flex;gap:8px;padding:10px 14px 0;align-items:center;} .cr-timer-custom-row {display:flex;gap:8px;padding:10px 14px 0;align-items:center;}
.cr-timer-custom-row[hidden] {display:none;} .cr-timer-custom-row[hidden] {display:none;}
.cr-time-fields {display:flex;gap:6px;flex:1;min-width:0;} /* Compact clock entry: 0 : 00 : 00, click a segment to edit it. */
.cr-tf-box {display:flex;align-items:center;gap:2px;flex:1;min-width:0;border:1px solid var(--bcborders,#c7cdd1);border-radius:8px;padding:4px 8px;background:color-mix(in srgb, var(--bctext-0,#2d3b45) 5%, transparent);} .cr-time-input {display:flex;align-items:center;justify-content:center;gap:1px;flex:1;min-width:0;border:1px solid var(--bcborders,#c7cdd1);border-radius:8px;padding:4px 8px;background:color-mix(in srgb, var(--bctext-0,#2d3b45) 5%, transparent);}
.cr-tf-box:focus-within {border-color:var(--bclinks,#0374b5);box-shadow:0 0 0 2px color-mix(in srgb, var(--bclinks,#0374b5) 22%, transparent);} .cr-time-input:focus-within {border-color:var(--bclinks,#0374b5);box-shadow:0 0 0 2px color-mix(in srgb, var(--bclinks,#0374b5) 22%, transparent);}
.cr-tf {width:100%;min-width:0;border:none;background:transparent;color:var(--bctext-0,#2d3b45);font:inherit;font-size:14px;font-weight:600;text-align:center;padding:2px 0;} .cr-tf {width:2.4ch;min-width:0;border:none;background:transparent;color:var(--bctext-0,#2d3b45);font:inherit;font-size:15px;font-weight:700;line-height:1.2;text-align:center;padding:0;border-radius:4px;}
.cr-tf:focus {outline:none;} .cr-tf:focus {outline:none;background:color-mix(in srgb, var(--bclinks,#0374b5) 20%, transparent);}
.cr-tf::-webkit-outer-spin-button,.cr-tf::-webkit-inner-spin-button {-webkit-appearance:none;margin:0;} .cr-tf::placeholder {color:var(--bctext-2,#9aa3ab);}
.cr-tf-unit {font-size:10px;font-weight:700;color:var(--bctext-2,#9aa3ab);text-transform:uppercase;} .cr-tf-colon {font-size:15px;font-weight:700;color:var(--bctext-2,#9aa3ab);padding-bottom:1px;}
.cr-timer-custom-row .cr-widget-btn {flex:none;padding:8px 14px;} .cr-timer-custom-row .cr-widget-btn {flex:none;padding:7px 13px;}
.cr-timer-controls {display:flex;gap:8px;padding:12px 14px 14px;} .cr-timer-controls {display:flex;gap:8px;padding:12px 14px 14px;}
/* Breathing Breaks: 4s in / 6s out, colour shifts green -> blue when exhaling */ /* Breathing Breaks: 4s in / 6s out, colour shifts green -> blue when exhaling */
.canvasrefined-breathing {background:linear-gradient(160deg, color-mix(in srgb, #6ea8ff 14%, var(--bcbackground-1,#fff)), var(--bcbackground-1,#fff));} .canvasrefined-breathing {background:linear-gradient(160deg, color-mix(in srgb, #6ea8ff 14%, var(--bcbackground-1,#fff)), var(--bcbackground-1,#fff));}
@ -634,59 +635,21 @@
.canvasrefined-breathing.is-running .cr-breath-phase-out {animation: cr-breath-out-label 10s ease-in-out infinite;} .canvasrefined-breathing.is-running .cr-breath-phase-out {animation: cr-breath-out-label 10s ease-in-out infinite;}
@keyframes cr-breath-in-label {0%{opacity:1;}38%{opacity:1;}44%{opacity:0;}100%{opacity:0;}} @keyframes cr-breath-in-label {0%{opacity:1;}38%{opacity:1;}44%{opacity:0;}100%{opacity:0;}}
@keyframes cr-breath-out-label {0%{opacity:0;}38%{opacity:0;}44%{opacity:1;}94%{opacity:1;}100%{opacity:0;}} @keyframes cr-breath-out-label {0%{opacity:0;}38%{opacity:0;}44%{opacity:1;}94%{opacity:1;}100%{opacity:0;}}
/* Music: mini player + slide-in library switcher (visual only) */ /* Breathing layout: circle + time stacked above the controls (card mode), and
.cr-music-list-btn {display:inline-flex;align-items:center;gap:5px;border:1px solid var(--bcborders,#c7cdd1);background:transparent;color:var(--bctext-0,#2d3b45);border-radius:999px;padding:4px 10px;font:inherit;font-size:11px;font-weight:600;cursor:pointer;} circle + time on the left with the controls on the right in Top/Bottom rows. */
.cr-music-list-btn:hover {border-color:var(--bctext-2,#9aa3ab);} .cr-breath-layout {display:flex;flex-direction:column;flex:1;min-height:0;}
.cr-music-caret {transition:transform .2s;} .cr-breath-left {display:flex;flex-direction:column;align-items:center;justify-content:center;}
.canvasrefined-music.is-drawer-open .cr-music-caret {transform:rotate(180deg);} .cr-breath-right {display:flex;flex-direction:column;justify-content:center;}
.canvasrefined-music {position:relative;min-height:320px;} .canvasrefined-breathing.container-mode .cr-breath-layout {flex-direction:row;align-items:center;gap:26px;}
.canvasrefined-music.container-mode {min-height:320px;} .canvasrefined-breathing.container-mode .cr-breath-left {flex:0 0 40%;width:40%;max-width:40%;}
.cr-player {display:flex;flex-direction:column;gap:12px;padding:16px 14px 10px;flex:1;justify-content:center;} .canvasrefined-breathing.container-mode .cr-breath-right {flex:1;min-width:0;}
.cr-player-thumb {display:none;position:relative;height:104px;border-radius:12px;overflow:hidden;background:linear-gradient(135deg, color-mix(in srgb, var(--cr-thumb-color,#0374b5) 82%, #000 8%), color-mix(in srgb, var(--cr-thumb-color,#0374b5) 38%, var(--bcbackground-1,#fff)));} .canvasrefined-breathing.container-mode .cr-breath-stage {padding:8px 0;}
.cr-player-thumb-mark {position:absolute;right:10px;bottom:8px;color:rgba(255,255,255,.55);line-height:0;} .canvasrefined-breathing.container-mode .cr-timer-presets {padding-top:0;}
.canvasrefined-music.thumb-bg .cr-player-thumb {display:block;} .canvasrefined-breathing.container-mode .cr-timer-controls {padding-bottom:0;}
.canvasrefined-music.thumb-bg .cr-player-art {display:none;} /* Daily Quote: text scales with the widget's own width (small grid cell vs.
.canvasrefined-music.no-thumb .cr-player-thumb, full-width container row), using container-query units. */
.canvasrefined-music.no-thumb .cr-player-art {display:none;} .canvasrefined-quotes {container-type:inline-size;background:linear-gradient(150deg, color-mix(in srgb, #b98cff 14%, var(--bcbackground-1,#fff)), var(--bcbackground-1,#fff));}
.cr-player-body {display:flex;align-items:center;gap:12px;} .cr-quote-body {padding:clamp(12px,4.5cqw,26px);display:flex;flex-direction:column;gap:clamp(4px,1.6cqw,12px);flex:1;justify-content:center;}
.cr-player-art {flex:none;width:56px;height:56px;border-radius:14px;display:flex;align-items:center;justify-content:center;background:color-mix(in srgb, var(--bclinks,#0374b5) 16%, transparent);color:var(--bclinks,#0374b5);} .cr-quote-mark {font-size:clamp(20px,11cqw,44px);line-height:.5;font-weight:800;color:color-mix(in srgb, var(--bclinks,#0374b5) 55%, transparent);}
.cr-player-info {min-width:0;flex:1;} .cr-quote-text {margin:0;font-size:clamp(14px,7.5cqw,26px);line-height:1.45;font-style:italic;color:var(--bctext-0,#2d3b45);}
.cr-player-title {font-size:14px;font-weight:700;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;} .cr-quote-author {font-size:clamp(11px,3.6cqw,17px);font-weight:600;color:var(--bctext-2,#9aa3ab);align-self:flex-end;}
.cr-player-sub {font-size:11.5px;color:var(--bctext-2,#9aa3ab);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.cr-player-progress {height:5px;margin:0 14px;border-radius:999px;background:color-mix(in srgb, var(--bctext-0,#2d3b45) 12%, transparent);overflow:hidden;}
.cr-player-progress-fill {height:100%;width:0;background:var(--bclinks,#0374b5);border-radius:999px;}
.cr-player-times {display:flex;justify-content:space-between;padding:5px 16px 0;font-size:10.5px;color:var(--bctext-2,#9aa3ab);font-variant-numeric:tabular-nums;}
.cr-player-controls {display:flex;align-items:center;justify-content:center;gap:16px;padding:10px 14px 14px;}
.cr-player-btn {display:flex;align-items:center;justify-content:center;width:36px;height:36px;border:none;border-radius:50%;background:transparent;color:var(--bctext-0,#2d3b45);cursor:pointer;}
.cr-player-btn:hover {background:color-mix(in srgb, var(--bctext-0,#2d3b45) 8%, transparent);}
.cr-player-play {width:46px;height:46px;background:var(--bclinks,#0374b5);color:#fff;}
.cr-player-play:hover {background:var(--bclinks,#0374b5);filter:brightness(1.08);}
/* Slide-in library drawer: platforms / playlists / songs */
.cr-music-drawer {position:absolute;inset:0;z-index:5;display:flex;flex-direction:column;background:var(--bcbackground-1,#fff);color:var(--bctext-0,#2d3b45);transform:translateX(102%);transition:transform .28s ease;visibility:hidden;box-shadow:-10px 0 26px rgba(0,0,0,.2);}
.canvasrefined-music.is-drawer-open .cr-music-drawer {transform:translateX(0);visibility:visible;}
.cr-drawer-head {display:flex;align-items:center;justify-content:space-between;padding:12px 14px;border-bottom:1px solid var(--bcborders,#c7cdd1);}
.cr-drawer-title {font-size:12px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:var(--bctext-2,#9aa3ab);}
.cr-drawer-close {border:none;background:transparent;color:var(--bctext-2,#9aa3ab);font-size:20px;line-height:1;cursor:pointer;padding:0 5px;border-radius:6px;}
.cr-drawer-close:hover {color:var(--bctext-0,#2d3b45);background:color-mix(in srgb, var(--bctext-0,#2d3b45) 8%, transparent);}
.cr-drawer-tabs {display:flex;gap:6px;padding:8px 12px;border-bottom:1px solid var(--bcborders,#c7cdd1);}
.cr-drawer-tab {flex:1;border:1px solid transparent;background:transparent;color:var(--bctext-2,#9aa3ab);border-radius:999px;padding:6px 8px;font:inherit;font-size:11.5px;font-weight:600;cursor:pointer;}
.cr-drawer-tab:hover {color:var(--bctext-0,#2d3b45);}
.cr-drawer-tab.active {background:color-mix(in srgb, var(--bclinks,#0374b5) 16%, transparent);color:var(--bclinks,#0374b5);border-color:color-mix(in srgb, var(--bclinks,#0374b5) 45%, transparent);}
.cr-drawer-body {flex:1;overflow-y:auto;padding:8px 10px 12px;}
.cr-drawer-pane[hidden] {display:none;}
.cr-drawer-row {display:flex;align-items:center;gap:10px;padding:8px;border-radius:9px;}
.cr-drawer-row:hover {background:color-mix(in srgb, var(--bctext-0,#2d3b45) 6%, transparent);}
.cr-drawer-icon {flex:none;display:flex;line-height:0;}
.cr-drawer-icon-pl {opacity:.85;}
.cr-drawer-meta {display:flex;flex-direction:column;min-width:0;flex:1;}
.cr-drawer-name {font-size:12.5px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.cr-drawer-sub {font-size:11px;color:var(--bctext-2,#9aa3ab);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.cr-drawer-chevron {flex:none;color:var(--bctext-2,#9aa3ab);font-size:16px;}
.cr-drawer-index,.cr-drawer-len {flex:none;font-size:11px;color:var(--bctext-2,#9aa3ab);font-variant-numeric:tabular-nums;}
.cr-drawer-index {width:16px;text-align:right;}
/* Daily Quote: looks only for now */
.canvasrefined-quotes {background:linear-gradient(150deg, color-mix(in srgb, #b98cff 14%, var(--bcbackground-1,#fff)), var(--bcbackground-1,#fff));}
.cr-quote-body {padding:14px 16px 16px;display:flex;flex-direction:column;gap:6px;flex:1;justify-content:center;}
.cr-quote-mark {font-size:24px;line-height:.5;font-weight:800;color:color-mix(in srgb, var(--bclinks,#0374b5) 55%, transparent);}
.cr-quote-text {margin:0;font-size:15px;line-height:1.5;font-style:italic;color:var(--bctext-0,#2d3b45);}
.cr-quote-author {font-size:12px;font-weight:600;color:var(--bctext-2,#9aa3ab);align-self:flex-end;}

View File

@ -407,11 +407,21 @@ input[type="checkbox"]:checked {background: #8dd28d;}
.widget-order-strip[data-area="cards_end"] .wos-item {flex: 1 1 0;} .widget-order-strip[data-area="cards_end"] .wos-item {flex: 1 1 0;}
/* Widget sections (Widgets tab): two side-by-side columns; add future /* Widget sections (Widgets tab): two side-by-side columns; add future
widgets as one more .option-container inside .widgets-grid. */ widgets as one more .option-container inside .widgets-grid. */
.widgets-grid {display:grid;grid-template-columns:1fr 1fr;gap:8px;align-items:stretch;margin-top:8px;} .widgets-grid {display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:8px;align-items:stretch;margin-top:8px;}
.widgets-grid .option-container {margin-top:0;box-sizing:border-box;} .widgets-grid .option-container {margin-top:0;box-sizing:border-box;min-width:0;}
/* Widget position dropdowns (Widgets tab) — mirror #todo_timeframe exactly: /* Widget position dropdowns (Widgets tab) — mirror #todo_timeframe exactly:
same font, padding, radius, colors and full-width stacked layout. */ same font, padding, radius, colors and full-width stacked layout. */
.widget-pos {font-size:12px;background:var(--inputbg);color:#e2e2e2;border:none;border-radius:7px;padding:6px 8px;font-family:inherit;width:100%;box-sizing:border-box;margin-top:2px;margin-bottom:6px;color-scheme:dark;} .widget-pos {font-size:12px;background:var(--inputbg);color:#e2e2e2;border:none;border-radius:7px;padding:6px 8px;font-family:inherit;width:100%;box-sizing:border-box;margin-top:2px;margin-bottom:6px;color-scheme:dark;}
.widget-pos option {background:#1e1e1e;color:#e2e2e2;} .widget-pos option {background:#1e1e1e;color:#e2e2e2;}
.widget-pos option:checked, .widget-pos option:checked,
.widget-pos option:hover {background:#2e2e2e;color:#fff;} .widget-pos option:hover {background:#2e2e2e;color:#fff;}
/* Inline rows: the label sits on the same line as its control. */
.widget-inline-row {display:flex;align-items:center;gap:8px;}
.widget-inline-label {flex:none;}
.widget-inline-select {flex:1;min-width:0;margin-top:0;margin-bottom:0;}
/* Compact clock entry (preset lengths), matching the on-canvas widget. */
.popup-time-input {display:flex;align-items:center;justify-content:center;gap:1px;flex:1;min-width:0;background:var(--inputbg);border-radius:7px;padding:4px 6px;}
.popup-time-input:focus-within {box-shadow:0 0 0 2px rgba(86,202,240,.35);}
.popup-tf {width:2.2ch;min-width:0;border:none;background:transparent;color:#e2e2e2;font:inherit;font-size:13px;font-weight:700;text-align:center;padding:0;border-radius:4px;}
.popup-tf:focus {outline:none;background:rgba(127,127,127,.28);}
.popup-tf-colon {font-size:13px;font-weight:700;color:#9aa3ab;}

View File

@ -1135,6 +1135,14 @@
</div> </div>
</div> </div>
<!-- Widgets: two-column grid; add one option-container per widget here --> <!-- Widgets: two-column grid; add one option-container per widget here -->
<div class="option-container">
<h3 class="header-small">Top / Bottom height</h3>
<div class="sub-option widget-inline-row">
<label for="widget_row_height" class="sub-text widget-inline-label">Height</label>
<input type="number" id="widget_row_height" class="card-input" min="0" max="2000" placeholder="auto" style="width:92px;margin:0;">
<span class="sub-text">px</span>
</div>
</div>
<div class="widgets-grid"> <div class="widgets-grid">
<div class="option-container"> <div class="option-container">
<div class="option" id="dashboard_notes"> <div class="option" id="dashboard_notes">
@ -1146,15 +1154,20 @@
</div><span class="option-name" data-i18n="dashboard_notes">Dashboard Notes</span> </div><span class="option-name" data-i18n="dashboard_notes">Dashboard Notes</span>
</div> </div>
<div class="sub-options"> <div class="sub-options">
<div class="sub-option" style="flex-direction:column;align-items:stretch;gap:4px;"> <div class="sub-option widget-inline-row">
<label for="dashboard_notes_position" class="sub-text" data-i18n="widget_position">Position</label> <label for="dashboard_notes_position" class="sub-text widget-inline-label" data-i18n="widget_position">Position</label>
<select id="dashboard_notes_position" class="widget-pos"> <select id="dashboard_notes_position" class="widget-pos widget-inline-select">
<option value="top" data-i18n="pos_top">Top</option> <option value="top" data-i18n="pos_top">Top</option>
<option value="cards_start" data-i18n="pos_cards_start">Before cards</option> <option value="cards_start" data-i18n="pos_cards_start">Before cards</option>
<option value="cards_end" data-i18n="pos_cards_end">After cards</option> <option value="cards_end" data-i18n="pos_cards_end">After cards</option>
<option value="below" data-i18n="pos_below">Bottom</option> <option value="below" data-i18n="pos_below">Bottom</option>
</select> </select>
</div> </div>
<div class="sub-option widget-inline-row" style="margin-top:6px;">
<label for="dashboard_notes_height" class="sub-text widget-inline-label">Cards height</label>
<input type="number" id="dashboard_notes_height" class="card-input" min="0" max="2000" placeholder="auto" style="width:92px;margin:0;">
<span class="sub-text">px</span>
</div>
</div> </div>
</div> </div>
<div class="option-container"> <div class="option-container">
@ -1168,15 +1181,20 @@
<span class="option-name" data-i18n="gpa_calculator">GPA calculator</span> <span class="option-name" data-i18n="gpa_calculator">GPA calculator</span>
</div> </div>
<div class="sub-options"> <div class="sub-options">
<div class="sub-option" style="flex-direction:column;align-items:stretch;gap:4px;"> <div class="sub-option widget-inline-row">
<label for="gpa_calc_position" class="sub-text" data-i18n="widget_position">Position</label> <label for="gpa_calc_position" class="sub-text widget-inline-label" data-i18n="widget_position">Position</label>
<select id="gpa_calc_position" class="widget-pos"> <select id="gpa_calc_position" class="widget-pos widget-inline-select">
<option value="top" data-i18n="pos_top">Top</option> <option value="top" data-i18n="pos_top">Top</option>
<option value="cards_start" data-i18n="pos_cards_start">Before cards</option> <option value="cards_start" data-i18n="pos_cards_start">Before cards</option>
<option value="cards_end" data-i18n="pos_cards_end">After cards</option> <option value="cards_end" data-i18n="pos_cards_end">After cards</option>
<option value="below" data-i18n="pos_below">Bottom</option> <option value="below" data-i18n="pos_below">Bottom</option>
</select> </select>
</div> </div>
<div class="sub-option widget-inline-row" style="margin-top:6px;">
<label for="gpa_calc_height" class="sub-text widget-inline-label">Cards height</label>
<input type="number" id="gpa_calc_height" class="card-input" min="0" max="2000" placeholder="auto" style="width:92px;margin:0;">
<span class="sub-text">px</span>
</div>
<div class="sub-option" style="margin-top:5px"> <div class="sub-option" style="margin-top:5px">
<input type="checkbox" id="gpa_calc_cumulative" name="gpa_calc_cumulative"> <input type="checkbox" id="gpa_calc_cumulative" name="gpa_calc_cumulative">
<label for="gpa_calc_cumulative" class="sub-text" data-i18n="gpa_calc_cumulative">Show cumulative GPA</label> <label for="gpa_calc_cumulative" class="sub-text" data-i18n="gpa_calc_cumulative">Show cumulative GPA</label>
@ -1197,37 +1215,48 @@
</div><span class="option-name" data-i18n="focus_timer">Focus Timer</span> </div><span class="option-name" data-i18n="focus_timer">Focus Timer</span>
</div> </div>
<div class="sub-options"> <div class="sub-options">
<div class="sub-option" style="flex-direction:column;align-items:stretch;gap:4px;"> <div class="sub-option widget-inline-row">
<label for="focus_timer_position" class="sub-text" data-i18n="widget_position">Position</label> <label for="focus_timer_position" class="sub-text widget-inline-label" data-i18n="widget_position">Position</label>
<select id="focus_timer_position" class="widget-pos"> <select id="focus_timer_position" class="widget-pos widget-inline-select">
<option value="top" data-i18n="pos_top">Top</option> <option value="top" data-i18n="pos_top">Top</option>
<option value="cards_start" data-i18n="pos_cards_start">Before cards</option> <option value="cards_start" data-i18n="pos_cards_start">Before cards</option>
<option value="cards_end" data-i18n="pos_cards_end">After cards</option> <option value="cards_end" data-i18n="pos_cards_end">After cards</option>
<option value="below" data-i18n="pos_below">Bottom</option> <option value="below" data-i18n="pos_below">Bottom</option>
</select> </select>
</div> </div>
<div class="sub-option" style="margin-top:6px;flex-direction:column;align-items:stretch;gap:3px;"> <div class="sub-option widget-inline-row" style="margin-top:6px;">
<span class="sub-text">Button 1</span> <label for="focus_timer_height" class="sub-text widget-inline-label">Cards height</label>
<div style="display:flex;gap:5px;align-items:center;"> <input type="number" id="focus_timer_height" class="card-input" min="0" max="2000" placeholder="auto" style="width:92px;margin:0;">
<input type="number" id="focus_timer_preset_1_h" class="card-input" min="0" max="99" value="0" style="width:46px;margin-top:0;"><span class="sub-text">h</span> <span class="sub-text">px</span>
<input type="number" id="focus_timer_preset_1_m" class="card-input" min="0" max="59" value="15" style="width:46px;margin-top:0;"><span class="sub-text">m</span> </div>
<input type="number" id="focus_timer_preset_1_s" class="card-input" min="0" max="59" value="0" style="width:46px;margin-top:0;"><span class="sub-text">s</span> <div class="sub-option widget-inline-row" style="margin-top:6px;">
<span class="sub-text widget-inline-label">Button 1</span>
<div class="popup-time-input">
<input type="text" class="popup-tf" id="focus_timer_preset_1_h" inputmode="numeric" maxlength="2" value="00">
<span class="popup-tf-colon">:</span>
<input type="text" class="popup-tf" id="focus_timer_preset_1_m" inputmode="numeric" maxlength="2" value="15">
<span class="popup-tf-colon">:</span>
<input type="text" class="popup-tf" id="focus_timer_preset_1_s" inputmode="numeric" maxlength="2" value="00">
</div> </div>
</div> </div>
<div class="sub-option" style="margin-top:6px;flex-direction:column;align-items:stretch;gap:3px;"> <div class="sub-option widget-inline-row" style="margin-top:6px;">
<span class="sub-text">Button 2</span> <span class="sub-text widget-inline-label">Button 2</span>
<div style="display:flex;gap:5px;align-items:center;"> <div class="popup-time-input">
<input type="number" id="focus_timer_preset_2_h" class="card-input" min="0" max="99" value="0" style="width:46px;margin-top:0;"><span class="sub-text">h</span> <input type="text" class="popup-tf" id="focus_timer_preset_2_h" inputmode="numeric" maxlength="2" value="00">
<input type="number" id="focus_timer_preset_2_m" class="card-input" min="0" max="59" value="25" style="width:46px;margin-top:0;"><span class="sub-text">m</span> <span class="popup-tf-colon">:</span>
<input type="number" id="focus_timer_preset_2_s" class="card-input" min="0" max="59" value="0" style="width:46px;margin-top:0;"><span class="sub-text">s</span> <input type="text" class="popup-tf" id="focus_timer_preset_2_m" inputmode="numeric" maxlength="2" value="25">
<span class="popup-tf-colon">:</span>
<input type="text" class="popup-tf" id="focus_timer_preset_2_s" inputmode="numeric" maxlength="2" value="00">
</div> </div>
</div> </div>
<div class="sub-option" style="margin-top:6px;flex-direction:column;align-items:stretch;gap:3px;"> <div class="sub-option widget-inline-row" style="margin-top:6px;">
<span class="sub-text">Button 3</span> <span class="sub-text widget-inline-label">Button 3</span>
<div style="display:flex;gap:5px;align-items:center;"> <div class="popup-time-input">
<input type="number" id="focus_timer_preset_3_h" class="card-input" min="0" max="99" value="0" style="width:46px;margin-top:0;"><span class="sub-text">h</span> <input type="text" class="popup-tf" id="focus_timer_preset_3_h" inputmode="numeric" maxlength="2" value="00">
<input type="number" id="focus_timer_preset_3_m" class="card-input" min="0" max="59" value="45" style="width:46px;margin-top:0;"><span class="sub-text">m</span> <span class="popup-tf-colon">:</span>
<input type="number" id="focus_timer_preset_3_s" class="card-input" min="0" max="59" value="0" style="width:46px;margin-top:0;"><span class="sub-text">s</span> <input type="text" class="popup-tf" id="focus_timer_preset_3_m" inputmode="numeric" maxlength="2" value="45">
<span class="popup-tf-colon">:</span>
<input type="text" class="popup-tf" id="focus_timer_preset_3_s" inputmode="numeric" maxlength="2" value="00">
</div> </div>
</div> </div>
</div> </div>
@ -1242,82 +1271,46 @@
</div><span class="option-name" data-i18n="breathing_breaks">Breathing Breaks</span> </div><span class="option-name" data-i18n="breathing_breaks">Breathing Breaks</span>
</div> </div>
<div class="sub-options"> <div class="sub-options">
<div class="sub-option" style="flex-direction:column;align-items:stretch;gap:4px;"> <div class="sub-option widget-inline-row">
<label for="breathing_position" class="sub-text" data-i18n="widget_position">Position</label> <label for="breathing_position" class="sub-text widget-inline-label" data-i18n="widget_position">Position</label>
<select id="breathing_position" class="widget-pos"> <select id="breathing_position" class="widget-pos widget-inline-select">
<option value="top" data-i18n="pos_top">Top</option> <option value="top" data-i18n="pos_top">Top</option>
<option value="cards_start" data-i18n="pos_cards_start">Before cards</option> <option value="cards_start" data-i18n="pos_cards_start">Before cards</option>
<option value="cards_end" data-i18n="pos_cards_end">After cards</option> <option value="cards_end" data-i18n="pos_cards_end">After cards</option>
<option value="below" data-i18n="pos_below">Bottom</option> <option value="below" data-i18n="pos_below">Bottom</option>
</select> </select>
</div> </div>
<div class="sub-option" style="margin-top:6px;flex-direction:column;align-items:stretch;gap:3px;"> <div class="sub-option widget-inline-row" style="margin-top:6px;">
<span class="sub-text">Button 1</span> <label for="breathing_height" class="sub-text widget-inline-label">Cards height</label>
<div style="display:flex;gap:5px;align-items:center;"> <input type="number" id="breathing_height" class="card-input" min="0" max="2000" placeholder="auto" style="width:92px;margin:0;">
<input type="number" id="breathing_preset_1_h" class="card-input" min="0" max="99" value="0" style="width:46px;margin-top:0;"><span class="sub-text">h</span> <span class="sub-text">px</span>
<input type="number" id="breathing_preset_1_m" class="card-input" min="0" max="59" value="3" style="width:46px;margin-top:0;"><span class="sub-text">m</span> </div>
<input type="number" id="breathing_preset_1_s" class="card-input" min="0" max="59" value="0" style="width:46px;margin-top:0;"><span class="sub-text">s</span> <div class="sub-option widget-inline-row" style="margin-top:6px;">
<span class="sub-text widget-inline-label">Button 1</span>
<div class="popup-time-input">
<input type="text" class="popup-tf" id="breathing_preset_1_m" inputmode="numeric" maxlength="2" value="03">
<span class="popup-tf-colon">:</span>
<input type="text" class="popup-tf" id="breathing_preset_1_s" inputmode="numeric" maxlength="2" value="00">
</div> </div>
</div> </div>
<div class="sub-option" style="margin-top:6px;flex-direction:column;align-items:stretch;gap:3px;"> <div class="sub-option widget-inline-row" style="margin-top:6px;">
<span class="sub-text">Button 2</span> <span class="sub-text widget-inline-label">Button 2</span>
<div style="display:flex;gap:5px;align-items:center;"> <div class="popup-time-input">
<input type="number" id="breathing_preset_2_h" class="card-input" min="0" max="99" value="0" style="width:46px;margin-top:0;"><span class="sub-text">h</span> <input type="text" class="popup-tf" id="breathing_preset_2_m" inputmode="numeric" maxlength="2" value="05">
<input type="number" id="breathing_preset_2_m" class="card-input" min="0" max="59" value="5" style="width:46px;margin-top:0;"><span class="sub-text">m</span> <span class="popup-tf-colon">:</span>
<input type="number" id="breathing_preset_2_s" class="card-input" min="0" max="59" value="0" style="width:46px;margin-top:0;"><span class="sub-text">s</span> <input type="text" class="popup-tf" id="breathing_preset_2_s" inputmode="numeric" maxlength="2" value="00">
</div> </div>
</div> </div>
<div class="sub-option" style="margin-top:6px;flex-direction:column;align-items:stretch;gap:3px;"> <div class="sub-option widget-inline-row" style="margin-top:6px;">
<span class="sub-text">Button 3</span> <span class="sub-text widget-inline-label">Button 3</span>
<div style="display:flex;gap:5px;align-items:center;"> <div class="popup-time-input">
<input type="number" id="breathing_preset_3_h" class="card-input" min="0" max="99" value="0" style="width:46px;margin-top:0;"><span class="sub-text">h</span> <input type="text" class="popup-tf" id="breathing_preset_3_m" inputmode="numeric" maxlength="2" value="10">
<input type="number" id="breathing_preset_3_m" class="card-input" min="0" max="59" value="10" style="width:46px;margin-top:0;"><span class="sub-text">m</span> <span class="popup-tf-colon">:</span>
<input type="number" id="breathing_preset_3_s" class="card-input" min="0" max="59" value="0" style="width:46px;margin-top:0;"><span class="sub-text">s</span> <input type="text" class="popup-tf" id="breathing_preset_3_s" inputmode="numeric" maxlength="2" value="00">
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div class="option-container">
<div class="option" id="music">
<input type="radio" id="off" name="music">
<input type="radio" id="on" name="music">
<div class="slider">
<div class="sliderknob"></div>
<div class="sliderbg"></div>
</div><span class="option-name" data-i18n="music">Music</span>
</div>
<div class="sub-options">
<div class="sub-option" style="flex-direction:column;align-items:stretch;gap:4px;">
<label for="music_position" class="sub-text" data-i18n="widget_position">Position</label>
<select id="music_position" class="widget-pos">
<option value="top" data-i18n="pos_top">Top</option>
<option value="cards_start" data-i18n="pos_cards_start">Before cards</option>
<option value="cards_end" data-i18n="pos_cards_end">After cards</option>
<option value="below" data-i18n="pos_below">Bottom</option>
</select>
</div>
<div class="sub-option" style="flex-direction:column;align-items:stretch;gap:4px;margin-top:6px;">
<label for="music_spotify_url" class="sub-text">Spotify playlist</label>
<input type="text" id="music_spotify_url" class="card-input" placeholder="https://open.spotify.com/playlist/...">
</div>
<div class="sub-option" style="flex-direction:column;align-items:stretch;gap:4px;margin-top:6px;">
<label for="music_apple_url" class="sub-text">Apple Music playlist</label>
<input type="text" id="music_apple_url" class="card-input" placeholder="https://music.apple.com/...">
</div>
<div class="sub-option" style="flex-direction:column;align-items:stretch;gap:4px;margin-top:6px;">
<label for="music_youtube_url" class="sub-text">YouTube playlist</label>
<input type="text" id="music_youtube_url" class="card-input" placeholder="https://youtube.com/playlist?...">
</div>
<div class="sub-option" style="margin-top:9px">
<input type="checkbox" id="music_thumb_bg" name="music_thumb_bg">
<label for="music_thumb_bg" class="sub-text">Thumbnail background</label>
</div>
<div class="sub-option" style="margin-top:5px">
<input type="checkbox" id="music_no_thumb" name="music_no_thumb">
<label for="music_no_thumb" class="sub-text">Remove thumbnail</label>
</div>
</div>
</div>
<div class="option-container"> <div class="option-container">
<div class="option" id="daily_quotes"> <div class="option" id="daily_quotes">
<input type="radio" id="off" name="daily_quotes"> <input type="radio" id="off" name="daily_quotes">
@ -1328,15 +1321,20 @@
</div><span class="option-name" data-i18n="daily_quotes">Daily Quotes</span> </div><span class="option-name" data-i18n="daily_quotes">Daily Quotes</span>
</div> </div>
<div class="sub-options"> <div class="sub-options">
<div class="sub-option" style="flex-direction:column;align-items:stretch;gap:4px;"> <div class="sub-option widget-inline-row">
<label for="daily_quotes_position" class="sub-text" data-i18n="widget_position">Position</label> <label for="daily_quotes_position" class="sub-text widget-inline-label" data-i18n="widget_position">Position</label>
<select id="daily_quotes_position" class="widget-pos"> <select id="daily_quotes_position" class="widget-pos widget-inline-select">
<option value="top" data-i18n="pos_top">Top</option> <option value="top" data-i18n="pos_top">Top</option>
<option value="cards_start" data-i18n="pos_cards_start">Before cards</option> <option value="cards_start" data-i18n="pos_cards_start">Before cards</option>
<option value="cards_end" data-i18n="pos_cards_end">After cards</option> <option value="cards_end" data-i18n="pos_cards_end">After cards</option>
<option value="below" data-i18n="pos_below">Bottom</option> <option value="below" data-i18n="pos_below">Bottom</option>
</select> </select>
</div> </div>
<div class="sub-option widget-inline-row" style="margin-top:6px;">
<label for="daily_quotes_height" class="sub-text widget-inline-label">Cards height</label>
<input type="number" id="daily_quotes_height" class="card-input" min="0" max="2000" placeholder="auto" style="width:92px;margin:0;">
<span class="sub-text">px</span>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -47,13 +47,6 @@ chrome.runtime.onInstalled.addListener(function () {
"focus_timer_preset_1": { "h": 0, "m": 15, "s": 0 }, "focus_timer_preset_1": { "h": 0, "m": 15, "s": 0 },
"focus_timer_preset_2": { "h": 0, "m": 25, "s": 0 }, "focus_timer_preset_2": { "h": 0, "m": 25, "s": 0 },
"focus_timer_preset_3": { "h": 0, "m": 45, "s": 0 }, "focus_timer_preset_3": { "h": 0, "m": 45, "s": 0 },
"music": false,
"music_position": "cards_end",
"music_spotify_url": "",
"music_apple_url": "",
"music_youtube_url": "",
"music_thumb_bg": true,
"music_no_thumb": false,
"breathing": false, "breathing": false,
"breathing_position": "cards_end", "breathing_position": "cards_end",
"breathing_preset_1": { "h": 0, "m": 3, "s": 0 }, "breathing_preset_1": { "h": 0, "m": 3, "s": 0 },
@ -61,6 +54,12 @@ chrome.runtime.onInstalled.addListener(function () {
"breathing_preset_3": { "h": 0, "m": 10, "s": 0 }, "breathing_preset_3": { "h": 0, "m": 10, "s": 0 },
"daily_quotes": false, "daily_quotes": false,
"daily_quotes_position": "top", "daily_quotes_position": "top",
"widget_row_height": 0,
"dashboard_notes_height": 0,
"gpa_calc_height": 0,
"focus_timer_height": 0,
"breathing_height": 0,
"daily_quotes_height": 0,
"better_todo": true, "better_todo": true,
"todo_hr24": false, "todo_hr24": false,
"todo_separate_scrollbar": false, "todo_separate_scrollbar": false,
@ -200,6 +199,10 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
getNasaBackground().then(sendResponse); getNasaBackground().then(sendResponse);
return true; return true;
} }
if (message?.type === "getDailyQuote") {
getDailyQuote().then(sendResponse);
return true;
}
}); });
let nasaRequestQueue = Promise.resolve(); let nasaRequestQueue = Promise.resolve();
@ -268,3 +271,49 @@ async function callNasaApi(dateStr) {
return await response.json().catch(() => null); return await response.json().catch(() => null);
} }
// --- Daily quote (stoic.tekloon.net) ---------------------------------------
// Cached in storage.local and refreshed at most once per local calendar day.
// Fetched from the extension worker (host_permissions) because the endpoint
// does not send CORS headers, so a page-context fetch would be blocked.
function dailyQuoteTodayKey() {
const d = new Date();
return d.getFullYear() + "-" + String(d.getMonth() + 1).padStart(2, "0") + "-" + String(d.getDate()).padStart(2, "0");
}
let dailyQuoteInFlight = null;
async function getDailyQuote() {
const today = dailyQuoteTodayKey();
let cached = null;
try {
const st = await chrome.storage.local.get("daily_quote");
cached = st && st.daily_quote;
} catch (e) { /* ignore storage issues */ }
// Once-a-day: reuse today's cached quote without hitting the network.
if (cached && cached.date === today && cached.quote) return cached;
if (dailyQuoteInFlight) return dailyQuoteInFlight;
const request = (async () => {
try {
const response = await fetch("https://stoic.tekloon.net/stoic-quote", { cache: "no-store" });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const json = await response.json();
const data = (json && json.data) || {};
const quote = String(data.quote || "").replace(/\s+/g, " ").trim();
const author = String(data.author || "").trim();
if (!quote) throw new Error("empty quote");
const entry = { date: today, quote, author };
await chrome.storage.local.set({ daily_quote: entry });
return entry;
} catch (e) {
console.warn("[CanvasRefined] Daily quote fetch failed:", e);
// Fall back to the previous day's quote rather than losing it.
return cached || null;
} finally {
dailyQuoteInFlight = null;
}
})();
dailyQuoteInFlight = request;
return request;
}

View File

@ -1024,26 +1024,20 @@ function applyOptionsChanges(changes) {
case "dashboard_notes_text": case "dashboard_notes_text":
case "dashboard_notes_mode": case "dashboard_notes_mode":
case "dashboard_notes_position": case "dashboard_notes_position":
case "dashboard_notes_height":
loadDashboardNotes(); loadDashboardNotes();
break; break;
case "focus_timer": case "focus_timer":
case "focus_timer_position": case "focus_timer_position":
case "focus_timer_height":
case "focus_timer_preset_1": case "focus_timer_preset_1":
case "focus_timer_preset_2": case "focus_timer_preset_2":
case "focus_timer_preset_3": case "focus_timer_preset_3":
loadFocusTimer(); loadFocusTimer();
break; break;
case "music":
case "music_position":
case "music_spotify_url":
case "music_apple_url":
case "music_youtube_url":
case "music_thumb_bg":
case "music_no_thumb":
loadMusicWidget();
break;
case "breathing": case "breathing":
case "breathing_position": case "breathing_position":
case "breathing_height":
case "breathing_preset_1": case "breathing_preset_1":
case "breathing_preset_2": case "breathing_preset_2":
case "breathing_preset_3": case "breathing_preset_3":
@ -1051,8 +1045,15 @@ function applyOptionsChanges(changes) {
break; break;
case "daily_quotes": case "daily_quotes":
case "daily_quotes_position": case "daily_quotes_position":
case "daily_quotes_height":
loadDailyQuotesWidget(); loadDailyQuotesWidget();
break; break;
case "daily_quote":
// Background refreshed the cached quote for today.
document.querySelectorAll(".canvasrefined-quotes").forEach(el => {
renderDailyQuote(el, changes["daily_quote"].newValue);
});
break;
case "widget_order": case "widget_order":
["top", "below"].forEach(area => { ["top", "below"].forEach(area => {
const row = document.querySelector(".canvasrefined-widget-row[data-area='" + area + "']"); const row = document.querySelector(".canvasrefined-widget-row[data-area='" + area + "']");
@ -1067,6 +1068,11 @@ function applyOptionsChanges(changes) {
if (options.gpa_calc === true && current_page === "/") setupGPACalc(); if (options.gpa_calc === true && current_page === "/") setupGPACalc();
loadAllSimpleWidgets(); loadAllSimpleWidgets();
break; break;
case "widget_row_height":
document.querySelectorAll(".canvasrefined-widget-row").forEach(row => {
applyWidgetRowLayout(row, row.dataset.area);
});
break;
case "dashboard_grades": case "dashboard_grades":
case "grade_hover": case "grade_hover":
case "card_letter": case "card_letter":
@ -1187,6 +1193,7 @@ function applyOptionsChanges(changes) {
case "gpa_calc": case "gpa_calc":
case "gpa_calc_prepend": case "gpa_calc_prepend":
case "gpa_calc_position": case "gpa_calc_position":
case "gpa_calc_height":
case "gpa_calc_weighted": case "gpa_calc_weighted":
case "gpa_calc_cumulative": case "gpa_calc_cumulative":
if (!grades) getGrades(); if (!grades) getGrades();
@ -1974,6 +1981,11 @@ function checkDashboardReady() {
const observer = new MutationObserver(callback); const observer = new MutationObserver(callback);
observer.observe(document.documentElement, { childList: true, subtree: true }); observer.observe(document.documentElement, { childList: true, subtree: true });
// On a fast load (or cached dashboard) Canvas can finish rendering before
// this observer is attached, so no structural mutation would ever trigger
// the setup pass and widgets (notably the GPA card) would only appear on
// the next unrelated DOM change. Kick one pass immediately.
callback([{ type: "childList" }]);
} }
function recieveMessage(request, sender, sendResponse) { function recieveMessage(request, sender, sendResponse) {
@ -7644,6 +7656,9 @@ GPA calculator
*/ */
function calculateGPA2() { function calculateGPA2() {
// The shell renders before the grades data arrives; nothing to compute until
// the cumulative row exists (populated by setupGPACalc).
if (!document.querySelector("#canvasrefined-cumulative-gpa")) return;
let qualityPoints = 0, numCredits = 0, weightedQualityPoints = 0, cumulativePoints = 0, cumulativeCredits = 0; let qualityPoints = 0, numCredits = 0, weightedQualityPoints = 0, cumulativePoints = 0, cumulativeCredits = 0;
document.querySelectorAll('.canvasrefined-gpa-course').forEach(course => { document.querySelectorAll('.canvasrefined-gpa-course').forEach(course => {
course.dataset.gradeLetter = "--"; course.dataset.gradeLetter = "--";
@ -7840,8 +7855,14 @@ function placeGPAElements(dashboardContainer, container, container2) {
// While the calculator is hosted in the open modal, leave it alone — a // While the calculator is hosted in the open modal, leave it alone — a
// dashboard setup pass must not yank it out from under the user. // dashboard setup pass must not yank it out from under the user.
if (container.closest && container.closest("#canvasrefined-gpa-modal")) return; if (container.closest && container.closest("#canvasrefined-gpa-modal")) return;
// Disabled: make sure the pair isn't still reserving a slot in a shared row.
if (options.gpa_calc !== true) {
releaseWidgetFromRow("gpa_calc", [container2, container], dashboardContainer);
return;
}
const pos = gpaCalcPosition(); const pos = gpaCalcPosition();
const containerMode = pos === "top" || pos === "below"; const containerMode = pos === "top" || pos === "below";
crApplyCardHeight("gpa_calc", container2, !containerMode);
container.classList.toggle("container-mode", containerMode); container.classList.toggle("container-mode", containerMode);
container2.classList.toggle("container-mode", containerMode); container2.classList.toggle("container-mode", containerMode);
const parent = dashboardContainer.parentNode; const parent = dashboardContainer.parentNode;
@ -7887,10 +7908,10 @@ function placeGPAElements(dashboardContainer, container, container2) {
// Every dashboard widget known to the placement system. Kept in one place so // Every dashboard widget known to the placement system. Kept in one place so
// the ordering/filtering helpers don't each hardcode their own list. // the ordering/filtering helpers don't each hardcode their own list.
const CR_WIDGET_IDS = ["dashboard_notes", "gpa_calc", "focus_timer", "music", "breathing", "daily_quotes"]; const CR_WIDGET_IDS = ["dashboard_notes", "gpa_calc", "focus_timer", "breathing", "daily_quotes"];
// Widgets rendered by the generic card loader (single root element, no GPA-style // Widgets rendered by the generic card loader (single root element, no GPA-style
// paired elements). // paired elements).
const CR_SIMPLE_WIDGET_IDS = ["focus_timer", "music", "breathing", "daily_quotes"]; const CR_SIMPLE_WIDGET_IDS = ["focus_timer", "breathing", "daily_quotes"];
function isCRWidget(id) { return CR_WIDGET_IDS.indexOf(id) !== -1; } function isCRWidget(id) { return CR_WIDGET_IDS.indexOf(id) !== -1; }
// Shared full-width container ("top"/"below" positions): widgets live in a // Shared full-width container ("top"/"below" positions): widgets live in a
@ -7930,7 +7951,7 @@ function applyCardWidgetOrder(grid) {
if (!grid) return; if (!grid) return;
const saved = cardWidgetOrder(); const saved = cardWidgetOrder();
const units = []; const units = [];
// Simple card widgets (focus timer, music, breathing, quotes) are a // Simple card widgets (focus timer, breathing, quotes) are a
// single element each, so they join the ordering straight from the DOM. // single element each, so they join the ordering straight from the DOM.
grid.querySelectorAll(":scope > .canvasrefined-widget-card[data-cr-widget]").forEach(el => { grid.querySelectorAll(":scope > .canvasrefined-widget-card[data-cr-widget]").forEach(el => {
if (!el.classList.contains("in-cards")) return; if (!el.classList.contains("in-cards")) return;
@ -8010,6 +8031,11 @@ function ensureWidgetRow(area, container) {
function applyWidgetRowLayout(row, area) { function applyWidgetRowLayout(row, area) {
const slots = [...row.querySelectorAll(":scope > .canvasrefined-widget-slot")]; const slots = [...row.querySelectorAll(":scope > .canvasrefined-widget-slot")];
if (!slots.length) { row.remove(); return; } if (!slots.length) { row.remove(); return; }
// Shared Top/Bottom row height (px; 0 = auto). Applied to the row so every
// widget in it matches; cards in container-mode stretch to fill it.
const rowHeight = parseInt(options.widget_row_height, 10);
if (Number.isFinite(rowHeight) && rowHeight > 0) row.style.height = rowHeight + "px";
else row.style.height = "";
const entries = containerWidgetOrder(area); const entries = containerWidgetOrder(area);
const byId = {}; const byId = {};
slots.forEach(slot => { byId[slot.dataset.widget] = slot; }); slots.forEach(slot => { byId[slot.dataset.widget] = slot; });
@ -8027,24 +8053,19 @@ function applyWidgetRowLayout(row, area) {
row.querySelectorAll(":scope > .canvasrefined-widget-divider").forEach(d => d.remove()); row.querySelectorAll(":scope > .canvasrefined-widget-divider").forEach(d => d.remove());
// Sizing: saved % values are normalized across the widgets actually in // Sizing: saved % values are normalized across the widgets actually in
// the row, so when one widget leaves (or a new one joins) the rest // the row. A widget with no saved size (e.g. just enabled) borrows the
// instantly redistribute to fill the freed space. flex-grow weights do // average of the sized ones, so the weights ALWAYS sum to 1 — otherwise a
// that redistribution automatically in CSS. // mix of sized + new widgets could exceed 100% or collapse a slot to 0.
const present = entries.filter(e => order.includes(e.id)); const byIdEntry = Object.fromEntries(entries.map(e => [e.id, e]));
const defined = present.filter(e => Number.isFinite(e.size) && e.size > 0); const raw = order.map(id => {
let weights; const e = byIdEntry[id];
if (defined.length === 0) { return e && Number.isFinite(e.size) && e.size > 0 ? e.size : null;
weights = order.map(() => 1); });
} else { const known = raw.filter(v => v !== null);
const totalDefined = defined.reduce((a, e) => a + e.size, 0); const fallback = known.length ? known.reduce((a, b) => a + b, 0) / known.length : 1;
const undefinedIds = order.filter(id => !defined.some(e => e.id === id)); const rawFilled = raw.map(v => v === null ? fallback : v);
const byIdEntry = Object.fromEntries(present.map(e => [e.id, e])); const totalWeight = rawFilled.reduce((a, b) => a + b, 0) || 1;
weights = order.map(id => { const weights = rawFilled.map(v => v / totalWeight);
const e = byIdEntry[id];
if (e && Number.isFinite(e.size) && e.size > 0) return e.size / totalDefined;
return undefinedIds.length ? Math.max(0, 100 - totalDefined) / undefinedIds.length / 100 : 0.01;
});
}
order.forEach((id, i) => { order.forEach((id, i) => {
byId[id].dataset.sizePct = weights[i] * 100; byId[id].dataset.sizePct = weights[i] * 100;
byId[id].style.flex = weights[i] + " 1 0%"; byId[id].style.flex = weights[i] + " 1 0%";
@ -8260,66 +8281,87 @@ function openGpaModal(dashboardContainer, container, container2) {
modal.addEventListener("mousedown", (e) => { if (e.target === modal) close(); }); modal.addEventListener("mousedown", (e) => { if (e.target === modal) close(); });
} }
// Bounded retry while Canvas finishes rendering the dashboard container, so
// the GPA card doesn't wait for the next MutationObserver pass.
let gpaSetupRetryTimer = null;
let gpaSetupAttempts = 0;
function setupGPACalc() { function setupGPACalc() {
if (current_page !== "/" && current_page !== "") return; if (current_page !== "/" && current_page !== "") return;
try { try {
const sortableContainer = document.querySelector(".ic-DashboardCard__box__container");
const dashboardContainer = sortableContainer || document.querySelector("#DashboardCard_Container");
if (!dashboardContainer) {
// The dashboard DOM isn't there yet (Canvas renders it async).
// Retry briefly instead of silently giving up, otherwise the GPA
// card waits for the next mutation pass to show up.
if (!gpaSetupRetryTimer && gpaSetupAttempts < 20) {
gpaSetupAttempts++;
gpaSetupRetryTimer = setTimeout(() => {
gpaSetupRetryTimer = null;
setupGPACalc();
}, 150);
}
return;
}
gpaSetupAttempts = 0;
let container2 = document.querySelector(".canvasrefined-gpa-card");
let container = document.querySelector(".canvasrefined-gpa");
const alreadyRendered = container2?.dataset?.canvasrefinedGpaRendered === "true" && container?.dataset?.canvasrefinedGpaRendered === "true";
if (!container2) {
container2 = document.createElement("div");
container2.className = "canvasrefined-gpa-card";
}
if (!container) {
container = document.createElement("div");
container.className = "canvasrefined-gpa";
}
container2.style.display = options.gpa_calc === true ? "inline-block" : "none";
if (!alreadyRendered) {
// Render the shell immediately (with "--" placeholders) so the GPA
// card joins the dashboard at the same time as every other widget.
// The numbers and course rows are filled in when the grades API
// resolves, which is the only part that actually needs the data.
container2.innerHTML = `<h3 class="canvasrefined-gpa-header">GPA</h3><div><div><p id="canvasrefined-gpa-unweighted">--</p><p>Current</p></div><div style="display:${options["gpa_calc_weighted"] ? "block" : "none"}"><p id="canvasrefined-gpa-weighted">--</p><p>Weighted</p></div><div style="display:${options["gpa_calc_cumulative"] ? "block" : "none"}"><p id="canvasrefined-gpa-cumulative">--</p><p>Cumulative</p></div></div>`;
let editBtn = makeElement("button", container2, { "className": "canvasrefined-gpa-edit-btn", "textContent": "Edit Calculator" });
container.innerHTML = '<h3 class="canvasrefined-gpa-header">GPA Calculator</h3><div class="canvasrefined-gpa-courses-container"><div class="canvasrefined-gpa-courses"></div></div>';
editBtn.addEventListener("click", () => openGpaModal(dashboardContainer, container, container2));
container.style.display = "none";
container2.dataset.canvasrefinedGpaRendered = "true";
container.dataset.canvasrefinedGpaRendered = "true";
} else {
const weighted = container2.querySelector("#canvasrefined-gpa-weighted")?.parentElement;
const cumulative = container2.querySelector("#canvasrefined-gpa-cumulative")?.parentElement;
if (weighted) weighted.style.display = options.gpa_calc_weighted ? "block" : "none";
if (cumulative) cumulative.style.display = options.gpa_calc_cumulative ? "block" : "none";
}
placeGPAElements(dashboardContainer, container, container2);
try {
if (sortableContainer && window.jQuery && window.jQuery.fn && window.jQuery.fn.sortable) {
window.jQuery(sortableContainer).sortable('refresh');
}
} catch (e) {}
// Populate course rows + numbers once the grades request resolves.
grades?.then(result => { grades?.then(result => {
const location = container.querySelector(".canvasrefined-gpa-courses");
const sortableContainer = document.querySelector(".ic-DashboardCard__box__container"); if (!location || !container.isConnected) return;
const dashboardContainer = sortableContainer || document.querySelector("#DashboardCard_Container"); if (container.dataset.canvasrefinedGpaPopulated !== "true") {
if (!dashboardContainer) return; location.textContent = "";
const cumulative = createGPACalcCourse(location, { "id": "cumulative", "enrollments": [{ "has_grading_periods": true, "current_period_computed_current_score": 0 }] });
let container2 = document.querySelector(".canvasrefined-gpa-card"); if (cumulative) cumulative.id = "canvasrefined-cumulative-gpa";
let container = document.querySelector(".canvasrefined-gpa");
const alreadyRendered = container2?.dataset?.canvasrefinedGpaRendered === "true" && container?.dataset?.canvasrefinedGpaRendered === "true";
if (!container2) {
container2 = document.createElement("div");
container2.className = "canvasrefined-gpa-card";
}
if (!container) {
container = document.createElement("div");
container.className = "canvasrefined-gpa";
}
container2.style.display = options.gpa_calc === true ? "inline-block" : "none";
if (!alreadyRendered) {
container2.innerHTML = `<h3 class="canvasrefined-gpa-header">GPA</h3><div><div><p id="canvasrefined-gpa-unweighted"></p><p>Current</p></div><div style="display:${options["gpa_calc_weighted"] ? "block" : "none"}"><p id="canvasrefined-gpa-weighted"></p><p>Weighted</p></div><div style="display:${options["gpa_calc_cumulative"] ? "block" : "none"}"><p id="canvasrefined-gpa-cumulative"></p><p>Cumulative</p></div></div>`;
let editBtn = makeElement("button", container2, { "className": "canvasrefined-gpa-edit-btn", "textContent": "Edit Calculator" });
container.innerHTML = '<h3 class="canvasrefined-gpa-header">GPA Calculator</h3><div class="canvasrefined-gpa-courses-container"><div class="canvasrefined-gpa-courses"></div></div>';
placeGPAElements(dashboardContainer, container, container2);
editBtn.textContent = "Edit Calculator";
editBtn.addEventListener("click", () => openGpaModal(dashboardContainer, container, container2));
let location = document.querySelector(".canvasrefined-gpa-courses");
if (!location) return;
let cumulative = createGPACalcCourse(location, { "id": "cumulative", "enrollments": [{ "has_grading_periods": true, "current_period_computed_current_score": 0 }] });
cumulative.id = "canvasrefined-cumulative-gpa";
result.forEach(course => createGPACalcCourse(location, course)); result.forEach(course => createGPACalcCourse(location, course));
container.dataset.canvasrefinedGpaPopulated = "true";
container.style.display = "none";
container2.dataset.canvasrefinedGpaRendered = "true";
container.dataset.canvasrefinedGpaRendered = "true";
} else {
const weighted = container2.querySelector("#canvasrefined-gpa-weighted")?.parentElement;
const cumulative = container2.querySelector("#canvasrefined-gpa-cumulative")?.parentElement;
if (weighted) weighted.style.display = options.gpa_calc_weighted ? "block" : "none";
if (cumulative) cumulative.style.display = options.gpa_calc_cumulative ? "block" : "none";
placeGPAElements(dashboardContainer, container, container2);
} }
try {
if (sortableContainer && window.jQuery && window.jQuery.fn && window.jQuery.fn.sortable) {
window.jQuery(sortableContainer).sortable('refresh');
}
} catch (e) {}
calculateGPA2(); calculateGPA2();
}).catch(e => logError(e)); }).catch(e => logError(e));
} catch (e) { } catch (e) {
@ -8612,6 +8654,7 @@ function wireDashboardNotes(notes) {
// below — full-width container below the grid, with some margin // below — full-width container below the grid, with some margin
function placeDashboardNotes(notes, container) { function placeDashboardNotes(notes, container) {
const pos = options.dashboard_notes_position || "top"; const pos = options.dashboard_notes_position || "top";
crApplyCardHeight("dashboard_notes", notes, pos === "cards_start" || pos === "cards_end");
if (pos === "top" || pos === "below") { if (pos === "top" || pos === "below") {
notes.classList.remove("in-cards"); notes.classList.remove("in-cards");
const slot = notes.closest && notes.closest(".canvasrefined-widget-slot"); const slot = notes.closest && notes.closest(".canvasrefined-widget-slot");
@ -8676,12 +8719,16 @@ function loadDashboardNotes() {
placeDashboardNotes(notes, container); placeDashboardNotes(notes, container);
} else { } else {
let notes = document.querySelector('.canvasrefined-dashboard-notes'); let notes = document.querySelector('.canvasrefined-dashboard-notes');
if (notes) notes.style.display = "none"; if (notes) {
notes.style.display = "none";
// Give its width back to the other widgets sharing the row.
releaseWidgetFromRow("dashboard_notes", [notes]);
}
} }
} }
/* /*
Generic dashboard widgets (Focus Timer, Music, Breathing Breaks, Daily Quote) Generic dashboard widgets (Focus Timer, Breathing Breaks, Daily Quote)
These share the placement/ordering machinery built for Dashboard Notes and the These share the placement/ordering machinery built for Dashboard Notes and the
GPA calculator, but render from one element each (see CR_SIMPLE_WIDGET_IDS). GPA calculator, but render from one element each (see CR_SIMPLE_WIDGET_IDS).
@ -8691,7 +8738,6 @@ const CR_WIDGET_DEFAULT_POSITION = {
dashboard_notes: "top", dashboard_notes: "top",
gpa_calc: "cards_end", gpa_calc: "cards_end",
focus_timer: "cards_end", focus_timer: "cards_end",
music: "cards_end",
breathing: "cards_end", breathing: "cards_end",
daily_quotes: "top", daily_quotes: "top",
}; };
@ -8702,11 +8748,27 @@ function crWidgetPosition(id) {
return CR_WIDGET_DEFAULT_POSITION[id] || "cards_end"; return CR_WIDGET_DEFAULT_POSITION[id] || "cards_end";
} }
// Per-widget height used when the widget sits before/after the course cards
// (px; 0 = auto). Cleared in container (Top/Bottom) mode so the shared row
// height controls that layout instead.
function crApplyCardHeight(id, el, inCards) {
if (!el) return;
const h = parseInt(options[id + "_height"], 10);
if (inCards && Number.isFinite(h) && h > 0) {
el.style.height = h + "px";
el.style.minHeight = h + "px";
} else {
el.style.height = "";
el.style.minHeight = "";
}
}
// Move a single-element widget to its configured spot, mirroring the Notes/GPA // Move a single-element widget to its configured spot, mirroring the Notes/GPA
// placement rules: top/below join the shared full-width row, cards_* sit in the // placement rules: top/below join the shared full-width row, cards_* sit in the
// course-card grid before/after the cards. // course-card grid before/after the cards.
function placeSimpleWidget(id, el, container) { function placeSimpleWidget(id, el, container) {
const pos = crWidgetPosition(id); const pos = crWidgetPosition(id);
crApplyCardHeight(id, el, pos === "cards_start" || pos === "cards_end");
if (pos === "top" || pos === "below") { if (pos === "top" || pos === "below") {
el.classList.remove("in-cards"); el.classList.remove("in-cards");
el.classList.add("container-mode"); el.classList.add("container-mode");
@ -8736,7 +8798,11 @@ function placeSimpleWidget(id, el, container) {
function crLoadWidget(id, spec) { function crLoadWidget(id, spec) {
const existing = document.querySelector("." + spec.className); const existing = document.querySelector("." + spec.className);
if (options[id] !== true) { if (options[id] !== true) {
if (existing) existing.style.display = "none"; if (existing) {
existing.style.display = "none";
// Give its width back to the other widgets sharing the row.
releaseWidgetFromRow(id, [existing]);
}
return; return;
} }
const container = document.querySelector("#DashboardCard_Container"); const container = document.querySelector("#DashboardCard_Container");
@ -8866,16 +8932,26 @@ function crTimerHideCustom(el) {
const row = el.querySelector(".cr-timer-custom-row"); const row = el.querySelector(".cr-timer-custom-row");
if (row) row.hidden = true; if (row) row.hidden = true;
} }
// Read the h/m/s custom fields as a whole number of seconds. // Read the compact h:mm:ss custom fields as a whole number of seconds.
function crTimerCustomSeconds(el) { function crTimerCustomSeconds(el) {
const num = (sel) => { const num = (sel) => {
const v = parseInt((el.querySelector(sel) || {}).value, 10); const v = parseInt((el.querySelector(sel) || {}).value, 10);
return Number.isFinite(v) && v > 0 ? v : 0; return Number.isFinite(v) && v > 0 ? v : 0;
}; };
const h = Math.min(99, num(".cr-tf-h")); return Math.min(99, num(".cr-tf-h")) * 3600
const m = Math.min(59, num(".cr-tf-m")); + Math.min(59, num(".cr-tf-m")) * 60
const s = Math.min(59, num(".cr-tf-s")); + Math.min(59, num(".cr-tf-s"));
return h * 3600 + m * 60 + s; }
// Seed the compact fields from the current timer length, two digits each.
function crTimerFillCustom(el, totalSec) {
const secs = Math.max(0, Math.round(totalSec || 0));
const put = (sel, v) => {
const inp = el.querySelector(sel);
if (inp) inp.value = String(v).padStart(2, "0");
};
put(".cr-tf-h", Math.min(99, Math.floor(secs / 3600)));
put(".cr-tf-m", Math.floor((secs % 3600) / 60));
put(".cr-tf-s", secs % 60);
} }
// Listeners are wired once (update passes only refresh config labels). // Listeners are wired once (update passes only refresh config labels).
function crTimerWireControls(id, el) { function crTimerWireControls(id, el) {
@ -8887,29 +8963,49 @@ function crTimerWireControls(id, el) {
}); });
}); });
// Custom: reveal inline hours/minutes/seconds fields. // Custom: a compact h:mm:ss entry. Click a segment to edit it, Tab / arrows
// move between segments, typing a second digit auto-advances, Enter sets.
const customBtn = el.querySelector(".cr-timer-custom"); const customBtn = el.querySelector(".cr-timer-custom");
const row = el.querySelector(".cr-timer-custom-row"); const row = el.querySelector(".cr-timer-custom-row");
const fields = [...el.querySelectorAll(".cr-timer-custom-row .cr-tf")];
const focusField = (i) => {
const inp = fields[i];
if (inp) { inp.focus(); inp.select(); }
};
const applyCustom = () => { const applyCustom = () => {
const sec = crTimerCustomSeconds(el); const sec = crTimerCustomSeconds(el);
if (sec <= 0) { if (sec <= 0) {
const mInput = el.querySelector(".cr-tf-m"); focusField(fields.length - 1);
if (mInput) { mInput.focus(); mInput.select(); }
return; return;
} }
crTimerSet(id, el, sec); crTimerSet(id, el, sec);
crTimerHideCustom(el); crTimerHideCustom(el);
}; };
if (customBtn && row) customBtn.addEventListener("click", () => { const openCustom = () => {
if (!row.hidden) { crTimerHideCustom(el); return; } crTimerFillCustom(el, crTimer(id).total);
row.hidden = false; row.hidden = false;
const mInput = el.querySelector(".cr-tf-m"); // Start on minutes when present, otherwise the first field.
if (mInput) { mInput.focus(); mInput.select(); } const mi = fields.findIndex(f => f.classList.contains("cr-tf-m"));
focusField(mi === -1 ? 0 : mi);
};
if (customBtn && row) customBtn.addEventListener("click", () => {
if (row.hidden) openCustom();
else crTimerHideCustom(el);
});
fields.forEach((inp, i) => {
inp.addEventListener("focus", () => inp.select());
inp.addEventListener("input", () => {
inp.value = inp.value.replace(/[^0-9]/g, "").slice(0, 2);
if (inp.value.length >= 2 && i < fields.length - 1) focusField(i + 1);
});
inp.addEventListener("keydown", e => {
if (e.key === "Enter") { e.preventDefault(); applyCustom(); }
else if (e.key === "Escape") { e.preventDefault(); crTimerHideCustom(el); }
else if ((e.key === "ArrowRight" || e.key === "Tab" && !e.shiftKey) && i < fields.length - 1) { e.preventDefault(); focusField(i + 1); }
else if ((e.key === "ArrowLeft" || e.key === "Tab" && e.shiftKey) && i > 0) { e.preventDefault(); focusField(i - 1); }
else if (e.key === "Backspace" && inp.value === "" && i > 0) { e.preventDefault(); focusField(i - 1); }
});
}); });
el.querySelectorAll(".cr-timer-custom-row .cr-tf").forEach(inp => inp.addEventListener("keydown", e => {
if (e.key === "Enter") { e.preventDefault(); applyCustom(); }
else if (e.key === "Escape") { e.preventDefault(); crTimerHideCustom(el); }
}));
const go = el.querySelector(".cr-timer-custom-go"); const go = el.querySelector(".cr-timer-custom-go");
if (go) go.addEventListener("click", applyCustom); if (go) go.addEventListener("click", applyCustom);
@ -8967,10 +9063,12 @@ const FOCUS_TIMER_HTML = `
<button type="button" class="cr-timer-preset cr-timer-custom">Custom</button> <button type="button" class="cr-timer-preset cr-timer-custom">Custom</button>
</div> </div>
<div class="cr-timer-custom-row" hidden> <div class="cr-timer-custom-row" hidden>
<div class="cr-time-fields"> <div class="cr-time-input">
<label class="cr-tf-box"><input type="number" class="cr-tf cr-tf-h" min="0" max="99" inputmode="numeric" placeholder="0"><span class="cr-tf-unit">h</span></label> <input type="text" class="cr-tf cr-tf-h" inputmode="numeric" maxlength="2" value="00" aria-label="Hours">
<label class="cr-tf-box"><input type="number" class="cr-tf cr-tf-m" min="0" max="59" inputmode="numeric" placeholder="0"><span class="cr-tf-unit">m</span></label> <span class="cr-tf-colon" aria-hidden="true">:</span>
<label class="cr-tf-box"><input type="number" class="cr-tf cr-tf-s" min="0" max="59" inputmode="numeric" placeholder="0"><span class="cr-tf-unit">s</span></label> <input type="text" class="cr-tf cr-tf-m" inputmode="numeric" maxlength="2" value="00" aria-label="Minutes">
<span class="cr-tf-colon" aria-hidden="true">:</span>
<input type="text" class="cr-tf cr-tf-s" inputmode="numeric" maxlength="2" value="00" aria-label="Seconds">
</div> </div>
<button type="button" class="cr-widget-btn cr-timer-custom-go">Set</button> <button type="button" class="cr-widget-btn cr-timer-custom-go">Set</button>
</div> </div>
@ -9008,31 +9106,37 @@ function breathingCfg() {
const BREATHING_HTML = ` const BREATHING_HTML = `
<div class="cr-widget-head"><span class="cr-widget-title">Breathing Breaks</span></div> <div class="cr-widget-head"><span class="cr-widget-title">Breathing Breaks</span></div>
<div class="cr-breath-stage"> <div class="cr-breath-layout">
<div class="cr-breath-circle"> <div class="cr-breath-left">
<span class="cr-breath-phase cr-breath-phase-in">Breathe in</span> <div class="cr-breath-stage">
<span class="cr-breath-phase cr-breath-phase-out">Breathe out</span> <div class="cr-breath-circle">
<span class="cr-breath-idle">Ready</span> <span class="cr-breath-phase cr-breath-phase-in">Breathe in</span>
<span class="cr-breath-phase cr-breath-phase-out">Breathe out</span>
<span class="cr-breath-idle">Ready</span>
</div>
</div>
<div class="cr-timer-time cr-timer-display cr-breath-display" aria-live="polite">05:00</div>
</div> </div>
</div> <div class="cr-breath-right">
<div class="cr-timer-time cr-timer-display cr-breath-display" aria-live="polite">05:00</div> <div class="cr-timer-presets">
<div class="cr-timer-presets"> <button type="button" class="cr-timer-preset" data-slot="1">3 min</button>
<button type="button" class="cr-timer-preset" data-slot="1">3 min</button> <button type="button" class="cr-timer-preset" data-slot="2">5 min</button>
<button type="button" class="cr-timer-preset" data-slot="2">5 min</button> <button type="button" class="cr-timer-preset" data-slot="3">10 min</button>
<button type="button" class="cr-timer-preset" data-slot="3">10 min</button> <button type="button" class="cr-timer-preset cr-timer-custom">Custom</button>
<button type="button" class="cr-timer-preset cr-timer-custom">Custom</button> </div>
</div> <div class="cr-timer-custom-row" hidden>
<div class="cr-timer-custom-row" hidden> <div class="cr-time-input">
<div class="cr-time-fields"> <input type="text" class="cr-tf cr-tf-m" inputmode="numeric" maxlength="2" value="00" aria-label="Minutes">
<label class="cr-tf-box"><input type="number" class="cr-tf cr-tf-h" min="0" max="99" inputmode="numeric" placeholder="0"><span class="cr-tf-unit">h</span></label> <span class="cr-tf-colon" aria-hidden="true">:</span>
<label class="cr-tf-box"><input type="number" class="cr-tf cr-tf-m" min="0" max="59" inputmode="numeric" placeholder="0"><span class="cr-tf-unit">m</span></label> <input type="text" class="cr-tf cr-tf-s" inputmode="numeric" maxlength="2" value="00" aria-label="Seconds">
<label class="cr-tf-box"><input type="number" class="cr-tf cr-tf-s" min="0" max="59" inputmode="numeric" placeholder="0"><span class="cr-tf-unit">s</span></label> </div>
<button type="button" class="cr-widget-btn cr-timer-custom-go">Set</button>
</div>
<div class="cr-timer-controls">
<button type="button" class="cr-widget-btn cr-timer-start">Start</button>
<button type="button" class="cr-widget-btn cr-widget-btn-ghost cr-timer-reset">Reset</button>
</div>
</div> </div>
<button type="button" class="cr-widget-btn cr-timer-custom-go">Set</button>
</div>
<div class="cr-timer-controls">
<button type="button" class="cr-widget-btn cr-timer-start">Start</button>
<button type="button" class="cr-widget-btn cr-widget-btn-ghost cr-timer-reset">Reset</button>
</div> </div>
`; `;
@ -9054,191 +9158,67 @@ function loadBreathingWidget() {
}); });
} }
/* ---- Music (looks only) ---- */ /* ---- Daily Quote (stoic.tekloon.net, refreshed once a day) ---- */
const MUSIC_PLATFORMS = [ const DAILY_QUOTE_DEFAULT = {
{ id: "spotify", name: "Spotify", option: "music_spotify_url", color: "#1DB954" }, quote: "The secret of getting ahead is getting started.",
{ id: "apple", name: "Apple Music", option: "music_apple_url", color: "#FA243C" }, author: "Mark Twain",
{ id: "youtube", name: "YouTube", option: "music_youtube_url", color: "#FF0000" },
];
const MUSIC_ICONS = {
spotify: '<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"><circle cx="12" cy="12" r="11" fill="#1DB954"/><path d="M7 9.6c3.3-1 7-.6 9.8 1M7.4 12.6c2.6-.8 5.4-.4 7.6 .9M8 15.5c2-.6 4-.3 5.6 .7" stroke="#fff" stroke-width="1.5" fill="none" stroke-linecap="round"/></svg>',
apple: '<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"><circle cx="12" cy="12" r="11" fill="#FA243C"/><path d="M15.2 6.5l-5 1.1a.8.8 0 0 0-.6.8v5.2a2 2 0 1 0 .9 1.7V9.3l4-0.9v3.6a2 2 0 1 0 .9 1.7V7.3a.8.8 0 0 0-.2-.8z" fill="#fff"/></svg>',
youtube: '<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"><rect x="1.5" y="5" width="21" height="14" rx="4" fill="#FF0000"/><path d="M10 9l5 3-5 3z" fill="#fff"/></svg>',
note: '<svg viewBox="0 0 24 24" width="26" height="26" fill="currentColor" aria-hidden="true"><path d="M20 3v11.6a3.6 3.6 0 1 1-2-3.2V6.3l-8 1.6v8.7a3.6 3.6 0 1 1-2-3.2V5.4L20 3z"/></svg>',
prev: '<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" aria-hidden="true"><path d="M6 6h2v12H6zM20 6v12l-9-6z"/></svg>',
play: '<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor" aria-hidden="true"><path d="M8 5v14l11-7z"/></svg>',
next: '<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" aria-hidden="true"><path d="M16 6h2v12h-2zM4 6v12l9-6z"/></svg>',
}; };
function musicPlatformLabel(url) {
try {
const host = new URL(url).hostname.replace(/^www\./, "");
return host || "Open playlist";
} catch (e) {
return "Open playlist";
}
}
// Sample library used by the switcher drawer until playback is wired up.
const MUSIC_SAMPLE_PLAYLISTS = {
spotify: ["Focus Flow", "Lo-fi Study"],
apple: ["Morning Mix", "Reading Room"],
youtube: ["Study With Me", "Ambient Spaces"],
};
const MUSIC_SAMPLE_SONGS = [
{ title: "Golden Hour", artist: "Ambient Co.", len: "3:12" },
{ title: "Slow Tide", artist: "Nova", len: "4:05" },
{ title: "Paper Planes", artist: "The Loft", len: "2:48" },
{ title: "Night Drive", artist: "Kite", len: "3:57" },
{ title: "Warm Static", artist: "Halcyon", len: "5:21" },
];
const MUSIC_HTML = `
<div class="cr-widget-head">
<span class="cr-widget-title">Music</span>
<button type="button" class="cr-music-list-btn">Playlists <span class="cr-music-caret" aria-hidden="true">&#9662;</span></button>
</div>
<div class="cr-player">
<div class="cr-player-thumb" aria-hidden="true"><span class="cr-player-thumb-mark">${MUSIC_ICONS.note}</span></div>
<div class="cr-player-body">
<div class="cr-player-art" aria-hidden="true">${MUSIC_ICONS.note}</div>
<div class="cr-player-info">
<div class="cr-player-title">Nothing playing</div>
<div class="cr-player-sub">Pick a playlist</div>
</div>
</div>
</div>
<div class="cr-player-progress"><div class="cr-player-progress-fill"></div></div>
<div class="cr-player-times"><span>0:00</span><span>0:00</span></div>
<div class="cr-player-controls">
<button type="button" class="cr-player-btn" aria-label="Previous track">${MUSIC_ICONS.prev}</button>
<button type="button" class="cr-player-btn cr-player-play" aria-label="Play">${MUSIC_ICONS.play}</button>
<button type="button" class="cr-player-btn" aria-label="Next track">${MUSIC_ICONS.next}</button>
</div>
<div class="cr-music-drawer" aria-hidden="true">
<div class="cr-drawer-head">
<span class="cr-drawer-title">Library</span>
<button type="button" class="cr-drawer-close" aria-label="Close">&times;</button>
</div>
<div class="cr-drawer-tabs">
<button type="button" class="cr-drawer-tab active" data-tab="platforms">Platforms</button>
<button type="button" class="cr-drawer-tab" data-tab="playlists">Playlists</button>
<button type="button" class="cr-drawer-tab" data-tab="songs">Songs</button>
</div>
<div class="cr-drawer-body">
<div class="cr-drawer-pane" data-pane="platforms">
${MUSIC_PLATFORMS.map(p => `
<div class="cr-drawer-row cr-drawer-platform" data-platform="${p.id}">
<span class="cr-drawer-icon">${MUSIC_ICONS[p.id]}</span>
<span class="cr-drawer-meta"><span class="cr-drawer-name">${p.name}</span><span class="cr-drawer-sub" data-role="platform-sub">Not linked</span></span>
<span class="cr-drawer-chevron" aria-hidden="true">&#8250;</span>
</div>`).join("")}
</div>
<div class="cr-drawer-pane" data-pane="playlists" hidden>
${MUSIC_PLATFORMS.map(p => `
<div class="cr-drawer-row cr-drawer-playlist" data-platform="${p.id}">
<span class="cr-drawer-icon cr-drawer-icon-pl">${MUSIC_ICONS[p.id]}</span>
<span class="cr-drawer-meta"><span class="cr-drawer-name" data-role="playlist-name">${MUSIC_SAMPLE_PLAYLISTS[p.id][0]}</span><span class="cr-drawer-sub">${p.name}</span></span>
<span class="cr-drawer-chevron" aria-hidden="true">&#8250;</span>
</div>`).join("")}
</div>
<div class="cr-drawer-pane" data-pane="songs" hidden>
${MUSIC_SAMPLE_SONGS.map((s, i) => `
<div class="cr-drawer-row cr-drawer-song">
<span class="cr-drawer-index">${i + 1}</span>
<span class="cr-drawer-meta"><span class="cr-drawer-name">${s.title}</span><span class="cr-drawer-sub">${s.artist}</span></span>
<span class="cr-drawer-len">${s.len}</span>
</div>`).join("")}
</div>
</div>
</div>
`;
// No playback yet: this only keeps the look in sync with the saved links and
// display options.
function updateMusicWidget(el) {
const noThumb = options.music_no_thumb === true;
const thumbBg = !noThumb && options.music_thumb_bg !== false;
el.classList.toggle("no-thumb", noThumb);
el.classList.toggle("thumb-bg", thumbBg);
const configured = [];
MUSIC_PLATFORMS.forEach(p => {
const url = (options[p.option] || "").trim();
if (url) configured.push({ p, url });
const sub = el.querySelector('.cr-drawer-platform[data-platform="' + p.id + '"] [data-role="platform-sub"]');
if (sub) sub.textContent = url ? musicPlatformLabel(url) : "Not linked";
const plName = el.querySelector('.cr-drawer-playlist[data-platform="' + p.id + '"] [data-role="playlist-name"]');
if (plName) plName.textContent = url ? musicPlatformLabel(url) : MUSIC_SAMPLE_PLAYLISTS[p.id][0];
});
// Mirror the first configured playlist into the player header.
const titleEl = el.querySelector(".cr-player-title");
const subEl = el.querySelector(".cr-player-sub");
const artEl = el.querySelector(".cr-player-art");
const first = configured[0];
if (first) {
if (titleEl) titleEl.textContent = first.p.name + " playlist";
if (subEl) subEl.textContent = musicPlatformLabel(first.url);
if (artEl) artEl.innerHTML = MUSIC_ICONS[first.p.id];
el.style.setProperty("--cr-thumb-color", first.p.color);
} else {
if (titleEl) titleEl.textContent = "Nothing playing";
if (subEl) subEl.textContent = "Pick a playlist";
if (artEl) artEl.innerHTML = MUSIC_ICONS.note;
el.style.removeProperty("--cr-thumb-color");
}
}
function wireMusicWidget(el) {
const btn = el.querySelector(".cr-music-list-btn");
const drawer = el.querySelector(".cr-music-drawer");
const setOpen = (v) => {
el.classList.toggle("is-drawer-open", v);
if (drawer) drawer.setAttribute("aria-hidden", v ? "false" : "true");
};
if (btn && drawer) btn.addEventListener("click", () => setOpen(!el.classList.contains("is-drawer-open")));
const close = el.querySelector(".cr-drawer-close");
if (close) close.addEventListener("click", () => setOpen(false));
el.querySelectorAll(".cr-drawer-tab").forEach(tab => {
tab.addEventListener("click", () => {
const name = tab.dataset.tab;
el.querySelectorAll(".cr-drawer-tab").forEach(t => t.classList.toggle("active", t === tab));
el.querySelectorAll(".cr-drawer-pane").forEach(p => { p.hidden = p.dataset.pane !== name; });
});
});
updateMusicWidget(el);
}
function loadMusicWidget() {
crLoadWidget("music", {
className: "canvasrefined-music",
html: MUSIC_HTML,
wire: wireMusicWidget,
update: updateMusicWidget,
});
}
/* ---- Daily Quote (looks only, content comes later) ---- */
const QUOTES_HTML = ` const QUOTES_HTML = `
<div class="cr-widget-head"><span class="cr-widget-title">Daily Quote</span></div> <div class="cr-widget-head"><span class="cr-widget-title">Daily Quote</span></div>
<div class="cr-quote-body"> <div class="cr-quote-body">
<span class="cr-quote-mark" aria-hidden="true">&#8220;</span> <span class="cr-quote-mark" aria-hidden="true">&#8220;</span>
<p class="cr-quote-text">The secret of getting ahead is getting started.</p> <p class="cr-quote-text">${DAILY_QUOTE_DEFAULT.quote}</p>
<span class="cr-quote-author">&mdash; Mark Twain</span> <span class="cr-quote-author">&mdash; ${DAILY_QUOTE_DEFAULT.author}</span>
</div> </div>
`; `;
function renderDailyQuote(el, data) {
if (!el) return;
const quote = data && data.quote ? data.quote : DAILY_QUOTE_DEFAULT.quote;
const author = data && data.author ? data.author : DAILY_QUOTE_DEFAULT.author;
const textEl = el.querySelector(".cr-quote-text");
const authorEl = el.querySelector(".cr-quote-author");
if (textEl) textEl.textContent = quote;
if (authorEl) authorEl.textContent = author ? "\u2014 " + author : "";
}
// Ask the background worker to refresh (it caches per calendar day, so this is
// a no-op after the first successful fetch of the day).
let dailyQuoteRequested = false;
function ensureDailyQuote() {
if (dailyQuoteRequested) return;
dailyQuoteRequested = true;
try {
chrome.runtime.sendMessage({ type: "getDailyQuote" }, () => {
// Reading lastError suppresses the unchecked-error warning; on
// failure allow a retry from a later pass.
if (chrome.runtime.lastError) dailyQuoteRequested = false;
});
} catch (e) {
dailyQuoteRequested = false;
}
}
function refreshDailyQuoteWidget(el) {
chrome.storage.local.get("daily_quote", st => {
if (st && st.daily_quote) renderDailyQuote(el, st.daily_quote);
});
ensureDailyQuote();
}
function loadDailyQuotesWidget() { function loadDailyQuotesWidget() {
crLoadWidget("daily_quotes", { crLoadWidget("daily_quotes", {
className: "canvasrefined-quotes", className: "canvasrefined-quotes",
html: QUOTES_HTML, html: QUOTES_HTML,
wire: refreshDailyQuoteWidget,
update: refreshDailyQuoteWidget,
}); });
} }
function loadAllSimpleWidgets() { function loadAllSimpleWidgets() {
loadFocusTimer(); loadFocusTimer();
loadMusicWidget();
loadBreathingWidget(); loadBreathingWidget();
loadDailyQuotesWidget(); loadDailyQuotesWidget();
} }
@ -10501,6 +10481,11 @@ function cleanCustomAssignments() {
function getGrades() { function getGrades() {
if (options.gpa_calc === true || options.dashboard_grades === true) { if (options.gpa_calc === true || options.dashboard_grades === true) {
grades = getData(`${domain}/api/v1/courses?${/*enrollment_state=active&*/""}include[]=concluded&include[]=total_scores&include[]=computed_current_score&include[]=current_grading_period_scores&per_page=100`); grades = getData(`${domain}/api/v1/courses?${/*enrollment_state=active&*/""}include[]=concluded&include[]=total_scores&include[]=computed_current_score&include[]=current_grading_period_scores&per_page=100`);
// Render the GPA card the moment grade data arrives, rather than waiting
// for the next dashboard mutation pass to call setupGPACalc().
if (options.gpa_calc === true) {
grades.then(() => setupGPACalc()).catch(() => {});
}
} }
} }

View File

@ -1,4 +1,4 @@
const syncedSwitches = ['remind', 'tab_icons', 'dark_mode', 'remlogo', 'full_width', 'auto_dark', 'assignments_due', 'gpa_calc', 'gradient_cards', 'disable_color_overlay', 'dashboard_grades', 'dashboard_notes', 'focus_timer', 'music', 'breathing', 'daily_quotes', 'better_todo', 'todo_celebrations_enabled', 'better_sidebar', 'condensed_cards', 'hide_new_canvas', 'hide_sequence_footer', 'center_cards', 'quiz_safe_mode', 'global_search', 'grade_analytics', 'hide_personal_details']; const syncedSwitches = ['remind', 'tab_icons', 'dark_mode', 'remlogo', 'full_width', 'auto_dark', 'assignments_due', 'gpa_calc', 'gradient_cards', 'disable_color_overlay', 'dashboard_grades', 'dashboard_notes', 'focus_timer', 'breathing', 'daily_quotes', 'better_todo', 'todo_celebrations_enabled', 'better_sidebar', 'condensed_cards', 'hide_new_canvas', 'hide_sequence_footer', 'center_cards', 'quiz_safe_mode', 'global_search', 'grade_analytics', 'hide_personal_details'];
const syncedSubOptions = [ const syncedSubOptions = [
"grade_analytics_zones", "grade_analytics_zones",
"hide_course_images", "hide_course_images",
@ -24,17 +24,17 @@ const syncedSubOptions = [
"focus_timer_preset_1", "focus_timer_preset_1",
"focus_timer_preset_2", "focus_timer_preset_2",
"focus_timer_preset_3", "focus_timer_preset_3",
"music_position",
"music_spotify_url",
"music_apple_url",
"music_youtube_url",
"music_thumb_bg",
"music_no_thumb",
"breathing_position", "breathing_position",
"breathing_preset_1", "breathing_preset_1",
"breathing_preset_2", "breathing_preset_2",
"breathing_preset_3", "breathing_preset_3",
"daily_quotes_position", "daily_quotes_position",
"widget_row_height",
"dashboard_notes_height",
"gpa_calc_height",
"focus_timer_height",
"breathing_height",
"daily_quotes_height",
"auto_dark", "auto_dark",
"auto_dark_start", "auto_dark_start",
"auto_dark_end", "auto_dark_end",
@ -150,13 +150,6 @@ const defaultOptions = {
"focus_timer_preset_1": { "h": 0, "m": 15, "s": 0 }, "focus_timer_preset_1": { "h": 0, "m": 15, "s": 0 },
"focus_timer_preset_2": { "h": 0, "m": 25, "s": 0 }, "focus_timer_preset_2": { "h": 0, "m": 25, "s": 0 },
"focus_timer_preset_3": { "h": 0, "m": 45, "s": 0 }, "focus_timer_preset_3": { "h": 0, "m": 45, "s": 0 },
"music": false,
"music_position": "cards_end",
"music_spotify_url": "",
"music_apple_url": "",
"music_youtube_url": "",
"music_thumb_bg": true,
"music_no_thumb": false,
"breathing": false, "breathing": false,
"breathing_position": "cards_end", "breathing_position": "cards_end",
"breathing_preset_1": { "h": 0, "m": 3, "s": 0 }, "breathing_preset_1": { "h": 0, "m": 3, "s": 0 },
@ -164,6 +157,14 @@ const defaultOptions = {
"breathing_preset_3": { "h": 0, "m": 10, "s": 0 }, "breathing_preset_3": { "h": 0, "m": 10, "s": 0 },
"daily_quotes": false, "daily_quotes": false,
"daily_quotes_position": "top", "daily_quotes_position": "top",
// Height settings (px; 0 = auto). widget_row_height applies to the
// shared Top/Bottom row; *_height applies to a widget in Before/After.
"widget_row_height": 0,
"dashboard_notes_height": 0,
"gpa_calc_height": 0,
"focus_timer_height": 0,
"breathing_height": 0,
"daily_quotes_height": 0,
"better_todo": true, "better_todo": true,
"better_sidebar": false, "better_sidebar": false,
"sidebar_scale": 100, "sidebar_scale": 100,
@ -1042,7 +1043,6 @@ const WIDGET_LIST = [
{ id: "dashboard_notes", nameKey: "dashboard_notes", shortKey: "widget_short_notes" }, { id: "dashboard_notes", nameKey: "dashboard_notes", shortKey: "widget_short_notes" },
{ id: "gpa_calc", nameKey: "gpa_calculator", shortKey: "widget_short_gpa" }, { id: "gpa_calc", nameKey: "gpa_calculator", shortKey: "widget_short_gpa" },
{ id: "focus_timer", nameKey: "focus_timer", shortKey: "widget_short_focus" }, { id: "focus_timer", nameKey: "focus_timer", shortKey: "widget_short_focus" },
{ id: "music", nameKey: "music", shortKey: "widget_short_music" },
{ id: "breathing", nameKey: "breathing_breaks", shortKey: "widget_short_breathing" }, { id: "breathing", nameKey: "breathing_breaks", shortKey: "widget_short_breathing" },
{ id: "daily_quotes", nameKey: "daily_quotes", shortKey: "widget_short_quotes" }, { id: "daily_quotes", nameKey: "daily_quotes", shortKey: "widget_short_quotes" },
]; ];
@ -1164,6 +1164,15 @@ function renderWidgetOrderStrip(strip, entries) {
strip.textContent = ""; strip.textContent = "";
// Before/after strips are sequence only — no width resizing, no dividers. // Before/after strips are sequence only — no width resizing, no dividers.
const resizable = !strip.dataset.area.startsWith("cards"); const resizable = !strip.dataset.area.startsWith("cards");
// Normalize widths to exactly 100%: a widget with no saved size borrows
// the average of the sized ones. Without this, adding a third widget to a
// previously 50/50 pair produced 50+50+33 = 133% and the strip overflowed.
let sizes = entries.map(e => (Number.isFinite(e.size) && e.size > 0) ? e.size : null);
const known = sizes.filter(v => v !== null);
const fallback = known.length ? known.reduce((a, b) => a + b, 0) / known.length : 100 / Math.max(1, entries.length);
sizes = sizes.map(v => v === null ? fallback : v);
const total = sizes.reduce((a, b) => a + b, 0) || 1;
const pcts = sizes.map(v => v / total * 100);
entries.forEach((entry, i) => { entries.forEach((entry, i) => {
if (resizable && i > 0) { if (resizable && i > 0) {
const divider = document.createElement("div"); const divider = document.createElement("div");
@ -1175,8 +1184,8 @@ function renderWidgetOrderStrip(strip, entries) {
item.className = "wos-item"; item.className = "wos-item";
item.dataset.id = entry.id; item.dataset.id = entry.id;
if (resizable) { if (resizable) {
item.dataset.size = entry.size == null ? (100 / entries.length) : entry.size; item.dataset.size = pcts[i];
item.style.flexBasis = item.dataset.size + "%"; item.style.flexBasis = pcts[i] + "%";
} }
item.draggable = true; item.draggable = true;
item.textContent = widgetDisplayName(entry.id); item.textContent = widgetDisplayName(entry.id);
@ -1268,7 +1277,6 @@ function setupWidgetPosition() {
{ select: "dashboard_notes_position", key: "dashboard_notes_position" }, { select: "dashboard_notes_position", key: "dashboard_notes_position" },
{ select: "gpa_calc_position", key: "gpa_calc_position" }, { select: "gpa_calc_position", key: "gpa_calc_position" },
{ select: "focus_timer_position", key: "focus_timer_position" }, { select: "focus_timer_position", key: "focus_timer_position" },
{ select: "music_position", key: "music_position" },
{ select: "breathing_position", key: "breathing_position" }, { select: "breathing_position", key: "breathing_position" },
{ select: "daily_quotes_position", key: "daily_quotes_position" }, { select: "daily_quotes_position", key: "daily_quotes_position" },
].forEach(({ select, key }) => { ].forEach(({ select, key }) => {
@ -1289,48 +1297,78 @@ function setupWidgetPosition() {
}); });
} }
// Widget height settings (px). 0 / blank means auto.
function setupWidgetHeightInput(id, initial) {
const el = document.getElementById(id);
if (!el) return;
const parse = (v) => {
const n = parseInt(v, 10);
return Number.isFinite(n) && n > 0 ? n : 0;
};
el.value = parse(initial) || "";
el.addEventListener("change", function () {
const n = Math.min(2000, Math.max(0, parseInt(this.value, 10) || 0));
this.value = n > 0 ? n : "";
chrome.storage.sync.set({ [id]: n });
});
}
// Hours/minutes/seconds widgets settings (Focus Timer / Breathing presets). // Hours/minutes/seconds widgets settings (Focus Timer / Breathing presets).
// The hours field is optional — Breathing only exposes minutes/seconds.
// Fields are compact h:mm:ss inputs: click to edit a segment, typing a second
// digit auto-advances, and the value is saved (zero-padded) on blur/change.
function setupWidgetTimeInput(prefix, initial) { function setupWidgetTimeInput(prefix, initial) {
const hEl = document.getElementById(prefix + "_h"); const hEl = document.getElementById(prefix + "_h");
const mEl = document.getElementById(prefix + "_m"); const mEl = document.getElementById(prefix + "_m");
const sEl = document.getElementById(prefix + "_s"); const sEl = document.getElementById(prefix + "_s");
if (!hEl || !mEl || !sEl) return; if (!mEl || !sEl) return;
let v = initial; let v = initial;
if (!v || typeof v !== "object") { if (!v || typeof v !== "object") {
// Legacy plain-minutes value. // Legacy plain-minutes value.
const n = parseInt(v, 10); const n = parseInt(v, 10);
v = Number.isFinite(n) && n > 0 ? { h: 0, m: n, s: 0 } : (defaultOptions.sync[prefix] || { h: 0, m: 0, s: 0 }); v = Number.isFinite(n) && n > 0 ? { h: 0, m: n, s: 0 } : (defaultOptions.sync[prefix] || { h: 0, m: 0, s: 0 });
} }
hEl.value = v.h || 0; const fields = [hEl, mEl, sEl].filter(Boolean);
mEl.value = v.m || 0; const pad = (n) => String(Math.max(0, parseInt(n, 10) || 0)).padStart(2, "0");
sEl.value = v.s || 0; if (hEl) hEl.value = pad(v.h);
mEl.value = pad(v.m);
sEl.value = pad(v.s);
const save = () => { const save = () => {
const clamp = (el, max) => Math.min(max, Math.max(0, parseInt(el.value, 10) || 0)); const clamp = (el, max) => Math.min(max, Math.max(0, parseInt(el.value, 10) || 0));
let h = clamp(hEl, 99), m = clamp(mEl, 59), s = clamp(sEl, 59); let h = hEl ? clamp(hEl, 99) : 0;
let m = clamp(mEl, 59);
let s = clamp(sEl, 59);
if (h === 0 && m === 0 && s === 0) { if (h === 0 && m === 0 && s === 0) {
const d = defaultOptions.sync[prefix] || { h: 0, m: 5, s: 0 }; const d = defaultOptions.sync[prefix] || { h: 0, m: 5, s: 0 };
h = d.h; m = d.m; s = d.s; h = hEl ? d.h : 0; m = d.m; s = d.s;
} }
hEl.value = h; mEl.value = m; sEl.value = s; if (hEl) hEl.value = pad(h);
mEl.value = pad(m);
sEl.value = pad(s);
chrome.storage.sync.set({ [prefix]: { h, m, s } }); chrome.storage.sync.set({ [prefix]: { h, m, s } });
}; };
[hEl, mEl, sEl].forEach(el => el.addEventListener("change", save));
}
// Text/URL widget settings (Music playlist links). fields.forEach((inp, i) => {
function setupWidgetTextInput(id, initial) { inp.addEventListener("focus", () => inp.select());
const el = document.getElementById(id); inp.addEventListener("input", () => {
if (!el) return; inp.value = inp.value.replace(/[^0-9]/g, "").slice(0, 2);
el.value = initial || ""; if (inp.value.length >= 2 && i < fields.length - 1) fields[i + 1].focus();
el.addEventListener("change", function () { });
chrome.storage.sync.set({ [id]: this.value.trim() }); inp.addEventListener("keydown", e => {
if (e.key === "Enter") { e.preventDefault(); save(); inp.blur(); }
else if (e.key === "ArrowRight" && i < fields.length - 1) { e.preventDefault(); fields[i + 1].focus(); }
else if (e.key === "ArrowLeft" && i > 0) { e.preventDefault(); fields[i - 1].focus(); }
});
inp.addEventListener("change", save);
inp.addEventListener("blur", save);
}); });
} }
// Hide a toggle's sub-options when it's off; auto_dark only hides its time // Hide a toggle's sub-options when it's off; auto_dark only hides its time
// clocks. // clocks.
function toggleSubOptionsVisibility(option, isOn) { function toggleSubOptionsVisibility(option, isOn) {
const togglesWithSubOptions = ["dashboard_notes", "gpa_calc", "focus_timer", "music", "breathing", "daily_quotes", "assignments_due", "better_todo", "auto_dark", "grade_analytics", "hide_personal_details"]; const togglesWithSubOptions = ["dashboard_notes", "gpa_calc", "focus_timer", "breathing", "daily_quotes", "assignments_due", "better_todo", "auto_dark", "grade_analytics", "hide_personal_details"];
if (!togglesWithSubOptions.includes(option)) return; if (!togglesWithSubOptions.includes(option)) return;
const optionEl = document.getElementById(option); const optionEl = document.getElementById(option);
if (!optionEl) return; if (!optionEl) return;
@ -1735,8 +1773,6 @@ function setup() {
"customBackgroundNasaDaily", "customBackgroundNasaDaily",
"fitImageToScreen", "fitImageToScreen",
"card_transparency", "card_transparency",
"music_thumb_bg",
"music_no_thumb",
"customCardStyles", "customCardStyles",
"card_grid", "card_grid",
"card_grid_center_rows", "card_grid_center_rows",
@ -1931,6 +1967,30 @@ function setup() {
identifier: "anon_progress_total", identifier: "anon_progress_total",
setup: (initial) => setupAnonProgressInput("anon_progress_total", initial, false), setup: (initial) => setupAnonProgressInput("anon_progress_total", initial, false),
}, },
{
identifier: "widget_row_height",
setup: (initial) => setupWidgetHeightInput("widget_row_height", initial),
},
{
identifier: "dashboard_notes_height",
setup: (initial) => setupWidgetHeightInput("dashboard_notes_height", initial),
},
{
identifier: "gpa_calc_height",
setup: (initial) => setupWidgetHeightInput("gpa_calc_height", initial),
},
{
identifier: "focus_timer_height",
setup: (initial) => setupWidgetHeightInput("focus_timer_height", initial),
},
{
identifier: "breathing_height",
setup: (initial) => setupWidgetHeightInput("breathing_height", initial),
},
{
identifier: "daily_quotes_height",
setup: (initial) => setupWidgetHeightInput("daily_quotes_height", initial),
},
{ {
identifier: "focus_timer_preset_1", identifier: "focus_timer_preset_1",
setup: (initial) => setupWidgetTimeInput("focus_timer_preset_1", initial), setup: (initial) => setupWidgetTimeInput("focus_timer_preset_1", initial),
@ -1955,18 +2015,6 @@ function setup() {
identifier: "breathing_preset_3", identifier: "breathing_preset_3",
setup: (initial) => setupWidgetTimeInput("breathing_preset_3", initial), setup: (initial) => setupWidgetTimeInput("breathing_preset_3", initial),
}, },
{
identifier: "music_spotify_url",
setup: (initial) => setupWidgetTextInput("music_spotify_url", initial),
},
{
identifier: "music_apple_url",
setup: (initial) => setupWidgetTextInput("music_apple_url", initial),
},
{
identifier: "music_youtube_url",
setup: (initial) => setupWidgetTextInput("music_youtube_url", initial),
},
{ {
identifier: "todo_more_expanded", identifier: "todo_more_expanded",
setup: (initial) => setupTodoMoreOptions(initial), setup: (initial) => setupTodoMoreOptions(initial),
@ -2012,7 +2060,7 @@ function setup() {
chrome.storage.sync.set({ "hide_course_images": false }); chrome.storage.sync.set({ "hide_course_images": false });
} }
toggleSubOptionsVisibility(option, status); toggleSubOptionsVisibility(option, status);
if (option === "dashboard_notes" || option === "gpa_calc" || option === "focus_timer" || option === "music" || option === "breathing" || option === "daily_quotes") { if (option === "dashboard_notes" || option === "gpa_calc" || option === "focus_timer" || option === "breathing" || option === "daily_quotes") {
if (refreshWidgetOrder) refreshWidgetOrder(); if (refreshWidgetOrder) refreshWidgetOrder();
} }
}); });
@ -2022,7 +2070,7 @@ function setup() {
chrome.storage.sync.get("todo_celebrations_enabled", v => { chrome.storage.sync.get("todo_celebrations_enabled", v => {
toggleCelebrationOptionsVisibility(v["todo_celebrations_enabled"] !== false); toggleCelebrationOptionsVisibility(v["todo_celebrations_enabled"] !== false);
}); });
["dashboard_notes", "gpa_calc", "focus_timer", "music", "breathing", "daily_quotes", "assignments_due", "better_todo", "auto_dark", "grade_analytics", "hide_personal_details"].forEach(opt => { ["dashboard_notes", "gpa_calc", "focus_timer", "breathing", "daily_quotes", "assignments_due", "better_todo", "auto_dark", "grade_analytics", "hide_personal_details"].forEach(opt => {
toggleSubOptionsVisibility(opt, sync[opt] === true); toggleSubOptionsVisibility(opt, sync[opt] === true);
}); });
toggleAlternateColorsVisibility(sync["dark_mode"] === true); toggleAlternateColorsVisibility(sync["dark_mode"] === true);

View File

@ -31,6 +31,7 @@
} }
], ],
"permissions": ["storage", "unlimitedStorage"], "permissions": ["storage", "unlimitedStorage"],
"host_permissions": ["https://stoic.tekloon.net/*"],
"default_locale": "en", "default_locale": "en",
"browser_specific_settings": { "browser_specific_settings": {
"gecko": { "gecko": {