mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-06 06:52:20 +02:00
Merge remote-tracking branch 'fork/v1/css-split-3' into lane/decomposition-and-test-isolation
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,962 @@
|
||||
/* Document library and editor, image gallery, image editor.
|
||||
*
|
||||
* Split out of style.css for maintainability, not for performance: this file
|
||||
* is loaded eagerly from index.html immediately after style.css, so the rules
|
||||
* land in the cascade in exactly the order they had inside the single file.
|
||||
* Nothing here is lazy-loaded.
|
||||
*
|
||||
* LOAD ORDER IS PART OF THE CONTRACT. The <link> tags in static/index.html and
|
||||
* the PRECACHE list in static/sw.js must both list the stylesheets in this
|
||||
* order:
|
||||
* 1. static/style.css
|
||||
* 2. static/css/documents-gallery-editor.css
|
||||
* 3. static/css/email-calendar-notes-tasks.css
|
||||
* 4. static/css/cookbook-research-memory-settings.css
|
||||
*
|
||||
* Rules were moved verbatim. A rule stays in style.css when its selector group
|
||||
* covers more than one panel, when the class is shared design language used by
|
||||
* modules outside this panel, or when moving it would change which of two
|
||||
* equally specific rules wins.
|
||||
*/
|
||||
|
||||
/* Document library search-term highlight — clear, high-contrast. */
|
||||
mark.doclib-search-hl {
|
||||
background: var(--accent, #e8a830);
|
||||
color: var(--bg, #1a1a1a);
|
||||
border-radius: 2px;
|
||||
padding: 0 2px;
|
||||
font-weight: 600;
|
||||
}
|
||||
/* Research bulk bar — right-align the action buttons (keep All + count on
|
||||
the left). Cancel now lives on the Select toggle, so Archive anchors. */
|
||||
#doclib-research-bulk #doclib-research-bulk-archive {
|
||||
margin-left: auto;
|
||||
}
|
||||
/* Drafts bulk bar defaults to justify-content:flex-end (whole row hugs the
|
||||
right). Reset it so All + count sit on the left and only the action button
|
||||
is pushed right — matching every other bulk bar. */
|
||||
#gallery-editor-drafts-bulk {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.doc-divider.doc-divider-handle-idle .doc-divider-collapse {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.doc-divider:hover .doc-divider-collapse { opacity: 0.92; }
|
||||
/* Smooth entrance — slide in from the left + fade up so it doesn't snap into
|
||||
place when fullscreen activates. The chevron's vertical centering uses
|
||||
translateY(-50%), so the animation has to keep that part. */
|
||||
@keyframes doc-fs-chevron-in {
|
||||
from { opacity: 0; transform: translateY(-50%) translateX(-18px); }
|
||||
to { opacity: 0.85; transform: translateY(-50%) translateX(0); }
|
||||
}
|
||||
.doc-editor-pane.doc-pane-leaving {
|
||||
pointer-events: none;
|
||||
}
|
||||
@keyframes doc-pane-leave {
|
||||
from { transform: translateX(0); opacity: 1; }
|
||||
to { transform: translateX(24px); opacity: 0; }
|
||||
}
|
||||
.doc-tab-scroll.is-at-left {
|
||||
-webkit-mask-image: linear-gradient(to right, #000 0, #000 calc(100% - 18px), transparent 100%);
|
||||
mask-image: linear-gradient(to right, #000 0, #000 calc(100% - 18px), transparent 100%);
|
||||
}
|
||||
.doc-tab-scroll.is-at-right {
|
||||
-webkit-mask-image: linear-gradient(to right, transparent 0, #000 18px, #000 100%);
|
||||
mask-image: linear-gradient(to right, transparent 0, #000 18px, #000 100%);
|
||||
}
|
||||
.doc-tab-scroll.is-at-left.is-at-right {
|
||||
-webkit-mask-image: none;
|
||||
mask-image: none;
|
||||
}
|
||||
.doc-tab-scroll::-webkit-scrollbar { display: none; }
|
||||
#doc-tab-right,
|
||||
#doc-tab-left {
|
||||
position: relative;
|
||||
top: 3px;
|
||||
}
|
||||
/* Ghost tab shown in the empty state (new, not-yet-saved document). Muted +
|
||||
italic so it reads as a placeholder, and non-interactive so clicking it
|
||||
can't hit the tab handlers (it has no data-doc-id). */
|
||||
.doc-tab.doc-tab-ghost {
|
||||
/* New, not-yet-saved doc tab. It already carries .active, so it shows the
|
||||
accent underline like any active tab — that's all we want. The old dashed
|
||||
border + italic/dim "pending" styling looked weird, so they're gone. */
|
||||
pointer-events: none;
|
||||
}
|
||||
.doc-tab.active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: var(--red);
|
||||
}
|
||||
@keyframes doc-stream-pulse {
|
||||
0%, 100% { opacity: 0.5; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
.doc-updated-flash {
|
||||
animation: doc-update-flash 0.6s ease-out;
|
||||
}
|
||||
@keyframes doc-update-flash {
|
||||
0% { box-shadow: inset 0 0 0 2px var(--accent); }
|
||||
100% { box-shadow: inset 0 0 0 2px transparent; }
|
||||
}
|
||||
.doc-langpicker-trigger.doc-langpicker-email-compact {
|
||||
width: 28px;
|
||||
min-width: 28px;
|
||||
padding: 0 0 0 6px;
|
||||
justify-content: flex-start;
|
||||
gap: 0;
|
||||
}
|
||||
/* The "T" icon stays centered in the fixed-size button so it grows
|
||||
symmetrically (from the center) instead of jumping around as the size
|
||||
changes. The S/M/L letter is pinned to the bottom-right corner, absolutely
|
||||
positioned so it never shifts the icon either. */
|
||||
#doc-fontsize-btn { transform: translateY(-1px); }
|
||||
#doc-fontsize-btn svg { flex: 0 0 auto; }
|
||||
.doc-overflow-wrapper { order: -1; }
|
||||
.doc-overflow-toggle { opacity: 0.5; }
|
||||
.doc-md-toolbar.md-preview-active,
|
||||
.doc-md-toolbar.md-write-active {
|
||||
overflow: visible;
|
||||
-webkit-mask-image: none;
|
||||
mask-image: none;
|
||||
z-index: 3;
|
||||
}
|
||||
.doc-md-toolbar #doc-render-view-toggle .md-view-opt {
|
||||
height: 28px;
|
||||
}
|
||||
/* Some toolbar buttons baked in svg{opacity:0.7} via inline styles, which
|
||||
compounded with the button's own opacity to look almost invisible.
|
||||
Force the inline svg opacity back to 1 inside the toolbar — the button
|
||||
wrapper's 0.85 is the only fade we need. */
|
||||
.doc-md-toolbar button svg[style*="opacity"] { opacity: 1 !important; }
|
||||
/* No black tap-flash / focus background on the doc toolbar + tab controls —
|
||||
they're icon buttons, the opacity change is the only feedback we want. */
|
||||
.doc-md-toolbar button,
|
||||
.doc-tab-new,
|
||||
.doc-tab-arrow,
|
||||
.doc-tab-play {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
/* Pull the icon down toward the "sign" label so they read as one unit. */
|
||||
#doc-pdf-add-sign-btn svg { transform: translateY(2px); }
|
||||
.doc-outline-count {
|
||||
opacity: 0.48;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.doc-outline-target {
|
||||
animation: doc-outline-target-flash 1.05s ease-out;
|
||||
}
|
||||
@keyframes doc-outline-target-flash {
|
||||
0%, 28% { background-color: color-mix(in srgb, var(--accent, var(--red)) 24%, transparent); }
|
||||
100% { background-color: transparent; }
|
||||
}
|
||||
.doc-rich-slash-count {
|
||||
opacity: 0.48;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.doc-rich-slash-label {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* Find marks live in the syntax-highlight overlay, which sits at
|
||||
z-index:0 under a transparent textarea — so they're always visible
|
||||
through the text layer. The previous color-mix variant could
|
||||
compute to near-invisible on themes with dark/desaturated accents;
|
||||
forced solid colors + !important so it ALWAYS pops. */
|
||||
mark.doc-find-mark {
|
||||
background: var(--accent) !important;
|
||||
color: var(--bg) !important;
|
||||
border-radius: 2px;
|
||||
padding: 0 1px;
|
||||
box-shadow: 0 0 0 1px var(--accent);
|
||||
}
|
||||
.doc-editor-highlight::-webkit-scrollbar { display: none; }
|
||||
/* Arrow pointing toward the editor */
|
||||
.doc-suggestion-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
left: -10px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
background: var(--panel);
|
||||
border-left: 2px solid var(--accent);
|
||||
border-bottom: 2px solid var(--accent);
|
||||
transform: rotate(45deg);
|
||||
z-index: 1;
|
||||
}
|
||||
.doc-suggestion-close::before {
|
||||
content: '';
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
display: block;
|
||||
background: currentColor;
|
||||
-webkit-mask: url("data:image/svg+xml,%3Csvg width='10' height='10' viewBox='0 0 10 10' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M2 2L8 8M8 2L2 8' stroke='black' stroke-width='1.8' stroke-linecap='round'/%3E%3C/svg%3E") center / contain no-repeat;
|
||||
mask: url("data:image/svg+xml,%3Csvg width='10' height='10' viewBox='0 0 10 10' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M2 2L8 8M8 2L2 8' stroke='black' stroke-width='1.8' stroke-linecap='round'/%3E%3C/svg%3E") center / contain no-repeat;
|
||||
}
|
||||
.doc-suggestion-accept:hover {
|
||||
filter: brightness(1.15);
|
||||
}
|
||||
.doc-editor-wrap.animating::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 6px; right: 8px;
|
||||
width: 6px; height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--red);
|
||||
animation: doc-pulse 0.8s ease-in-out infinite;
|
||||
z-index: 3;
|
||||
pointer-events: none;
|
||||
}
|
||||
@keyframes doc-pulse {
|
||||
0%, 100% { opacity: 0.3; transform: scale(0.8); }
|
||||
50% { opacity: 1; transform: scale(1.2); }
|
||||
}
|
||||
.doc-diff-content { }
|
||||
@media (hover: none) {
|
||||
.doc-preview-hover-edit {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
#doclib-research-search { position: relative; top: -1.5px; }
|
||||
/* Restore sits on the right of the Delete in archive previews. */
|
||||
.doclib-chat-restore-btn { margin-left: auto; }
|
||||
/* Delete + Archive pin to the left of the actions row; the
|
||||
`margin-right: auto` on the last left-side button (Archive) pushes
|
||||
Copy + Open to the right. Delete sits furthest left. */
|
||||
.doclib-chat-archive-btn { margin-right: auto; }
|
||||
@keyframes doclib-expand {
|
||||
from { opacity: 0.5; transform: translateY(4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
/* Hide the section headers while a card is expanded (full-screen reader). */
|
||||
.doclib-grid:has(.doclib-card-expanded) > .skills-section-header { display: none; }
|
||||
.skill-card:not(.doclib-card-expanded) .skill-card-tags { display: none; }
|
||||
.doclib-chip-scroll-frame > .chip-scroll-strip:empty {
|
||||
display: none;
|
||||
}
|
||||
.skill-card.doclib-card-expanded .skill-kebab-btn { display: none; }
|
||||
.skill-card.doclib-card-expanded .skill-chevron-up { display: inline-flex; }
|
||||
/* Size the expanded skill card to its content (footer sits right under the
|
||||
preview) but cap at the modal height so a long SKILL.md scrolls instead
|
||||
of overflowing. The inherited `.doclib-card-expanded { flex: 1 }` forced
|
||||
the card to fill the whole modal, which left a big empty void between a
|
||||
short skill's text and the footer. */
|
||||
.skill-card.doclib-card-expanded {
|
||||
flex: 0 1 auto !important;
|
||||
max-height: 100% !important;
|
||||
}
|
||||
.doclib-inline-load-more::before {
|
||||
display: none;
|
||||
}
|
||||
#doclib-selected-count {
|
||||
color: color-mix(in srgb, var(--fg) 50%, transparent);
|
||||
font-size: 10px;
|
||||
}
|
||||
.gallery-modal-content:has(#gallery-editor-container[style*="flex"]) .gallery-editor-landing {
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
#gallery-bulk-count,
|
||||
#gallery-editor-drafts-bulk-count {
|
||||
top: -1px !important;
|
||||
}
|
||||
#gallery-select-btn { position: relative; top: 4px !important; font-size: 12px; padding: 10px 11px 12px; min-width: 76px; justify-content: center; }
|
||||
#gallery-albums-search { top: -4px; }
|
||||
#gallery-albums-select-btn { top: -4px !important; }
|
||||
#gallery-bulk-delete,
|
||||
#gallery-albums-bulk-delete,
|
||||
#gallery-editor-drafts-bulk-delete {
|
||||
color: var(--color-error, #f44) !important;
|
||||
}
|
||||
.gallery-detail-image video {
|
||||
max-width: 100%;
|
||||
max-height: 70vh;
|
||||
display: block;
|
||||
}
|
||||
.gallery-card:hover .gallery-fav-btn { opacity: 1; }
|
||||
.gallery-card:hover .gallery-dl-btn { opacity: 1; }
|
||||
@media (hover: none) {
|
||||
.gallery-dl-btn { opacity: 0.55; }
|
||||
}
|
||||
/* Space the user's tag chips off the "add tag" input below them. */
|
||||
#gallery-user-tag-chips { margin-bottom: 4px; }
|
||||
.gallery-user-chip:hover .gallery-tag-x { opacity: 0.85; }
|
||||
@keyframes gallery-skeleton-shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
.gallery-card:hover .gallery-card-overlay,
|
||||
.gallery-card.gallery-card-focus .gallery-card-overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
/* Strong accent ring on the whole photo when selected. Paint it as a
|
||||
foreground pseudo-element so the thumbnail cannot cover the feedback. */
|
||||
.gallery-card:has(.gallery-select-dot.selected) {
|
||||
border-color: var(--accent-primary, var(--red));
|
||||
}
|
||||
.gallery-card:has(.gallery-select-dot.selected)::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 3;
|
||||
border: 3px solid var(--accent-primary, var(--red));
|
||||
border-radius: inherit;
|
||||
box-sizing: border-box;
|
||||
pointer-events: none;
|
||||
}
|
||||
.gallery-card:has(.gallery-select-dot.selected) .gallery-select-dot {
|
||||
z-index: 4;
|
||||
}
|
||||
.gallery-detail-action.danger:hover {
|
||||
border-color: var(--color-error, #e55);
|
||||
}
|
||||
.gallery-detail-image img,
|
||||
.gallery-detail-image video,
|
||||
.gallery-detail-img-frame img,
|
||||
.gallery-detail-img-frame video {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
border-radius: 6px;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
}
|
||||
.gallery-detail-image:hover .gallery-detail-nav { opacity: 0.85; }
|
||||
@media (min-width: 769px) {
|
||||
.gallery-modal-content:has(#gallery-detail[style*="flex"]) {
|
||||
height: 92vh;
|
||||
}
|
||||
.gallery-modal-content:has(#gallery-detail[style*="flex"]) > .modal-body,
|
||||
.gallery-images-container:has(> #gallery-detail[style*="flex"]),
|
||||
#gallery-images-container:has(> #gallery-detail[style*="flex"]) > #gallery-detail {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
.gallery-detail-image:hover .gallery-detail-rotate { opacity: 0.85; }
|
||||
.gallery-detail-nav-disabled {
|
||||
opacity: 0 !important;
|
||||
pointer-events: none;
|
||||
}
|
||||
@media (hover: none) {
|
||||
.gallery-detail-nav { opacity: 0.7; }
|
||||
}
|
||||
.gallery-detail-inline-link:hover { text-decoration: underline; }
|
||||
.gallery-detail-section {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.gallery-tag-input-wrap .gallery-tag-input { padding-right: 24px; }
|
||||
.gallery-name-wrap:focus-within .gallery-name-enter { display: inline-flex; }
|
||||
.gallery-name-wrap:focus-within .gallery-detail-name-input { padding-right: 28px; }
|
||||
.gallery-album-cover img {
|
||||
width: 100%; height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
.gallery-editor-landing p {
|
||||
margin: 0;
|
||||
opacity: 0.6;
|
||||
font-size: 13px;
|
||||
max-width: 320px;
|
||||
}
|
||||
.gallery-editor-landing-actions .gallery-select-btn:hover { opacity: 1; }
|
||||
/* Saved-project selection uses the same bulk-bar rhythm as Photos. */
|
||||
#gallery-editor-drafts-bulk {
|
||||
margin-top: 0;
|
||||
}
|
||||
.gallery-editor-draft-card.selected {
|
||||
outline: 2px solid var(--accent, var(--red));
|
||||
outline-offset: -2px;
|
||||
}
|
||||
.gallery-editor-draft-card.select-mode { cursor: pointer; }
|
||||
.gallery-drafts-view-toggle button svg {
|
||||
display: block;
|
||||
position: static;
|
||||
transform: none;
|
||||
}
|
||||
.gallery-editor-drafts-grid.list-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.gallery-editor-drafts-grid.list-view .gallery-editor-draft-card {
|
||||
display: grid;
|
||||
grid-template-columns: 44px minmax(0, 1fr) 24px;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
min-height: 48px;
|
||||
padding: 4px 6px;
|
||||
}
|
||||
.gallery-editor-drafts-grid.list-view .gallery-editor-draft-delete-x { display: none; }
|
||||
.gallery-editor-draft-card:focus-visible {
|
||||
outline: 2px solid var(--accent, var(--red));
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.gallery-editor-draft-card:hover .gallery-editor-draft-delete { opacity: 1; }
|
||||
.ge-topbar.ge-topbar-menu-open { z-index: 10006; }
|
||||
/* Toolbar (left) */
|
||||
@keyframes ge-layer-action-flash {
|
||||
from { box-shadow: inset 0 0 0 2px var(--red); background-color: color-mix(in srgb, var(--red) 24%, transparent); }
|
||||
to { box-shadow: inset 0 0 0 2px transparent; }
|
||||
}
|
||||
.ge-layer-action-flash { animation: ge-layer-action-flash 650ms ease-out; }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.ge-layer-action-flash { animation-duration: 1ms; }
|
||||
}
|
||||
.ge-tool-btn .ge-tool-label {
|
||||
max-width: 100%;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.ge-tool-btn:hover::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0 2px 0 -2px;
|
||||
background: color-mix(in srgb, var(--fg) 8%, transparent);
|
||||
border-radius: 6px;
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
}
|
||||
.ge-tool-btn.active::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
/* Highlight pill shifted 2 px left of the button bounds so it sits
|
||||
under the icon + label (which are also nudged left). Same total
|
||||
width as before — just slid. */
|
||||
inset: 0 2px 0 -2px;
|
||||
background: color-mix(in srgb, var(--red) 18%, transparent);
|
||||
border-radius: 6px;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.ge-btn-ai:hover .ge-btn-ai-mark { opacity: 1; }
|
||||
.ge-tool-btn.has-selection .ge-tool-clear { display: inline-flex; }
|
||||
.ge-canvas-area.ge-rulers-visible {
|
||||
padding: 18px 0 0 18px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.ge-main-canvas {
|
||||
touch-action: none;
|
||||
}
|
||||
.ge-canvas-area.ge-pan-ready,
|
||||
.ge-canvas-area.ge-pan-ready .ge-main-canvas {
|
||||
cursor: grab !important;
|
||||
}
|
||||
.ge-canvas-area.ge-panning,
|
||||
.ge-canvas-area.ge-panning .ge-main-canvas {
|
||||
cursor: grabbing !important;
|
||||
user-select: none;
|
||||
}
|
||||
.ge-ai-command-collapsed .ge-ai-command-toggle {
|
||||
display: inline-flex;
|
||||
}
|
||||
.ge-ai-command-collapsed .ge-ai-command-head,
|
||||
.ge-ai-command-collapsed .ge-ai-command-form,
|
||||
.ge-ai-command-collapsed .ge-ai-command-suggestions,
|
||||
.ge-ai-command-collapsed .ge-ai-command-status {
|
||||
display: none;
|
||||
}
|
||||
@media (hover: none) {
|
||||
.ge-ai-command-collapsed {
|
||||
bottom: 8px;
|
||||
opacity: 0.72;
|
||||
}
|
||||
}
|
||||
.ge-ai-command-clear[hidden] {
|
||||
display: none;
|
||||
}
|
||||
.ge-ai-command-busy .ge-ai-command-run::after {
|
||||
content: '';
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border: 2px solid color-mix(in srgb, #fff 42%, transparent);
|
||||
border-top-color: #fff;
|
||||
border-radius: 50%;
|
||||
animation: ge-ai-command-spin 0.7s linear infinite;
|
||||
}
|
||||
@keyframes ge-ai-command-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
.ge-ai-command-suggestions[hidden] {
|
||||
display: none;
|
||||
}
|
||||
.ge-ai-command-status:empty {
|
||||
display: none;
|
||||
}
|
||||
/* Each shortcut row: key chips + description. The key chips are usually
|
||||
chained with "+" so we use a small horizontal gap between siblings,
|
||||
plus a larger left margin on the trailing text via the kbd:last-of-type
|
||||
sibling rule — keeps "Ctrl+Shift+D Deselect" readable instead of glued. */
|
||||
.ge-shortcuts-col > div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
padding: 4px 0;
|
||||
opacity: 0.85;
|
||||
line-height: 1.5;
|
||||
}
|
||||
/* Push the description text away from the last kbd by adding margin
|
||||
to any text node that follows a kbd. Flex+wrap handles wrap on narrow. */
|
||||
.ge-shortcuts-col > div kbd + kbd { margin-left: 0; }
|
||||
.ge-shortcuts-col > div kbd:last-of-type { margin-right: 6px; }
|
||||
.ge-shortcuts-card kbd,
|
||||
#ge-shortcuts-popover kbd {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
padding: 2px 6px;
|
||||
border: 1px solid color-mix(in srgb, var(--accent, #cc6a3a) 55%, transparent);
|
||||
border-bottom-width: 2px;
|
||||
border-radius: 4px;
|
||||
background: color-mix(in srgb, var(--accent, #cc6a3a) 14%, transparent);
|
||||
color: var(--accent, #cc6a3a);
|
||||
min-width: 18px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.ge-quick-mask-bar[hidden] { display: none; }
|
||||
.ge-marquee-dimensions[hidden] { display: none; }
|
||||
.ge-layer-geometry-section .ge-section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
.ge-layer-geometry-grid input::-webkit-inner-spin-button,
|
||||
.ge-layer-geometry-grid input::-webkit-outer-spin-button { appearance: none; }
|
||||
#ge-brush-controls select,
|
||||
#ge-eyedropper-section select {
|
||||
height: 26px;
|
||||
min-width: 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
font: inherit;
|
||||
}
|
||||
.ge-gradient-extra-stop .ge-text-field { min-width: 0; }
|
||||
.ge-shape-gradient-add-stop { align-self: flex-start; }
|
||||
.ge-text-font-field { grid-column: span 1; }
|
||||
#ge-text-rasterize { align-self: flex-end; }
|
||||
.ge-shape-types .ge-text-toggle { flex: 1 1 25%; }
|
||||
#ge-shape-rasterize { align-self: flex-end; }
|
||||
.ge-direct-text-editor:focus { outline: 1px solid color-mix(in srgb, var(--red) 55%, transparent); }
|
||||
.ge-transform-popup.ge-transform-popup-dragging .ge-transform-popup-head { cursor: grabbing; }
|
||||
/* Force the title to take all middle space so the [_][×] cluster
|
||||
stays pinned to the right edge. */
|
||||
.ge-transform-popup-head .ge-adj-title { flex: 1 1 auto !important; }
|
||||
.ge-transform-popup-minimised .ge-transform-popup-body,
|
||||
.ge-transform-popup-minimised .ge-transform-popup-hint {
|
||||
display: none;
|
||||
}
|
||||
.ge-transform-popup-input::-webkit-outer-spin-button,
|
||||
.ge-transform-popup-input::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
margin: 0;
|
||||
display: none;
|
||||
}
|
||||
.ge-transform-position-field .ge-transform-popup-input { width: 68px; }
|
||||
.ge-transform-input-locked {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.ge-transform-input-locked + .ge-transform-spin { display: none; }
|
||||
.ge-transform-preserve-field[hidden] { display: none; }
|
||||
/* Hide the controls wrapper entirely when every tool section inside is
|
||||
hidden — otherwise the padding + border draws an empty band above the
|
||||
layers panel even when no tool needs sliders/buttons. */
|
||||
.ge-controls:not(:has(> *:not([style*="display: none"]):not([style*="display:none"]))) {
|
||||
display: none;
|
||||
}
|
||||
/* Strip the native <input type="color"> chrome (border + padding around
|
||||
the swatch in each engine) so the visible color fills the circle. */
|
||||
.ge-color-picker::-webkit-color-swatch-wrapper { padding: 0; }
|
||||
.ge-color-picker::-webkit-color-swatch {
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.ge-color-picker::-moz-color-swatch {
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.ge-color-picker.cp-swatch-input::selection { background: transparent; }
|
||||
.ge-size-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 9px; height: 9px;
|
||||
border-radius: 50%;
|
||||
background: var(--red);
|
||||
border: none; cursor: pointer;
|
||||
}
|
||||
.ge-size-slider:hover::-webkit-slider-thumb,
|
||||
.ge-size-slider.is-using::-webkit-slider-thumb,
|
||||
.ge-size-slider:active::-webkit-slider-thumb {
|
||||
width: 16px; height: 16px;
|
||||
}
|
||||
.ge-size-slider::-moz-range-thumb {
|
||||
width: 9px; height: 9px;
|
||||
border-radius: 50%;
|
||||
background: var(--red);
|
||||
border: none; cursor: pointer;
|
||||
}
|
||||
.ge-size-slider:hover::-moz-range-thumb,
|
||||
.ge-size-slider.is-using::-moz-range-thumb,
|
||||
.ge-size-slider:active::-moz-range-thumb {
|
||||
width: 16px; height: 16px;
|
||||
}
|
||||
.ge-eraser-row label > span[id$="-label"]:hover {
|
||||
background: color-mix(in srgb, var(--fg) 10%, transparent);
|
||||
}
|
||||
.ge-eraser-row > span[id$="-label"].ge-slider-value:hover {
|
||||
background: color-mix(in srgb, var(--fg) 10%, transparent);
|
||||
opacity: 1;
|
||||
}
|
||||
.ge-merge-menu[hidden] { display: none; }
|
||||
/* Inpaint Brush size slider — slightly larger thumb than the rest of
|
||||
the panel since it's the primary interactive control. */
|
||||
#ge-inpaint-brush-slider::-webkit-slider-thumb { width: 12px; height: 12px; }
|
||||
#ge-inpaint-brush-slider:hover::-webkit-slider-thumb,
|
||||
#ge-inpaint-brush-slider.is-using::-webkit-slider-thumb,
|
||||
#ge-inpaint-brush-slider:active::-webkit-slider-thumb { width: 18px; height: 18px; }
|
||||
#ge-inpaint-brush-slider::-moz-range-thumb { width: 12px; height: 12px; }
|
||||
#ge-inpaint-brush-slider:hover::-moz-range-thumb,
|
||||
#ge-inpaint-brush-slider.is-using::-moz-range-thumb,
|
||||
#ge-inpaint-brush-slider:active::-moz-range-thumb { width: 18px; height: 18px; }
|
||||
.ge-adj-popup .ge-adj-title { flex: 1 1 auto; }
|
||||
.ge-adj-popup .ge-adj-head:active { cursor: grabbing; }
|
||||
.ge-fx-dock-label { overflow: hidden; text-overflow: ellipsis; }
|
||||
.ge-history-head:active { cursor: grabbing; }
|
||||
@keyframes ge-canvas-spin { to { transform: rotate(360deg); } }
|
||||
.ge-inline-swatch::-webkit-color-swatch-wrapper { padding: 0; }
|
||||
.ge-inline-swatch::-webkit-color-swatch { border: none; border-radius: 2px; }
|
||||
.ge-inline-swatch::-moz-color-swatch { border: none; border-radius: 2px; }
|
||||
.ge-adj-cb-row .ge-adj-cb-dot {
|
||||
width: 10px; height: 10px; border-radius: 50%;
|
||||
display: inline-block; flex-shrink: 0;
|
||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--fg) 20%, transparent);
|
||||
}
|
||||
.ge-adj-cb-tone-picker {
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
.ge-adj-hist-details {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.ge-adj-hist-details > summary {
|
||||
font-size: 11px;
|
||||
color: var(--fg-muted);
|
||||
cursor: pointer;
|
||||
padding: 4px 0;
|
||||
list-style: none;
|
||||
user-select: none;
|
||||
}
|
||||
.ge-adj-hist-details > summary::-webkit-details-marker { display: none; }
|
||||
.ge-adj-hist-details > summary::before {
|
||||
content: '▸ ';
|
||||
display: inline-block;
|
||||
transition: transform 0.12s;
|
||||
}
|
||||
.ge-mask-sub-item.active .ge-layer-name { opacity: 1; font-style: normal; font-weight: 600; }
|
||||
.ge-mask-sub-item.active .ge-mask-inline-thumb { opacity: 1; }
|
||||
/* Opacity preview — translucent disk */
|
||||
#ge-eraser-preview-opacity { opacity: 0.5; }
|
||||
/* Flow preview — dotted ring to suggest discrete stamping */
|
||||
#ge-eraser-preview-flow {
|
||||
background: none;
|
||||
border: 2px dotted var(--fg);
|
||||
opacity: 0.7;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
/* Softness preview — blurred edge */
|
||||
#ge-eraser-preview-softness {
|
||||
background: radial-gradient(circle, var(--fg) 0%, var(--fg) 30%, transparent 75%);
|
||||
opacity: 0.7;
|
||||
}
|
||||
/* Wand feather preview — solid disk that visibly softens at its edge
|
||||
as the user drags the Feather slider. JS sets --feather-blur. */
|
||||
#ge-wand-feather-preview {
|
||||
filter: blur(var(--feather-blur, 0px));
|
||||
opacity: 0.8;
|
||||
}
|
||||
.ge-mask-vis-btn.visible:hover { opacity: 1; }
|
||||
.ge-topbar .ge-btn,
|
||||
.ge-topbar .ge-btn-sm { text-transform: uppercase; }
|
||||
.ge-stacked-btn .ge-stacked-glyph {
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 14px;
|
||||
}
|
||||
.ge-layer-selection-bar[hidden] { display: none; }
|
||||
.ge-layer-selection-bar .ge-icon-btn:hover { opacity: 1; }
|
||||
.ge-save-menu[hidden] { display: none; }
|
||||
.ge-stacked-btn .ge-stacked-glyph .ge-save-state-icon[hidden] { display: none !important; }
|
||||
.ge-stacked-btn .ge-stacked-glyph .ge-save-state-icon:not([hidden]) { display: block; }
|
||||
.ge-resize-menu[hidden],
|
||||
.ge-edge-menu[hidden],
|
||||
.ge-view-menu[hidden],
|
||||
.ge-image-menu[hidden],
|
||||
.ge-selection-menu[hidden],
|
||||
.ge-filter-menu[hidden] { display: none; }
|
||||
.ge-selection-save-row .ge-icon-btn { width: 28px; height: 27px; }
|
||||
.ge-export-matte .ge-color-picker { width: 28px; height: 28px; }
|
||||
.ge-export-footer > div { display: flex; gap: 6px; }
|
||||
.ge-export-estimate { opacity: 0.6; font-variant-numeric: tabular-nums; }
|
||||
.ge-canvas-prompt-custom-label { margin-top: 14px; }
|
||||
.ge-canvas-anchor::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 6px;
|
||||
border-radius: 1px;
|
||||
background: color-mix(in srgb, var(--fg) 38%, transparent);
|
||||
}
|
||||
.ge-canvas-anchor.active::after { background: var(--red); }
|
||||
.ge-canvas-lock-option input { accent-color: var(--red); }
|
||||
.ge-canvas-preset-icon { margin-bottom: 1px; }
|
||||
/* Center grip dots so the handle reads as draggable. */
|
||||
.ge-panel-resize::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 2px;
|
||||
height: 24px;
|
||||
background: color-mix(in srgb, var(--fg) 35%, transparent);
|
||||
border-radius: 2px;
|
||||
}
|
||||
.ge-slider-bubble[hidden] { display: none; }
|
||||
/* Tail pointing DOWN at the slider (bubble sits above). */
|
||||
.ge-slider-bubble::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: -4px;
|
||||
transform: translateX(-50%);
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: var(--red);
|
||||
clip-path: polygon(0 0, 100% 0, 50% 100%);
|
||||
}
|
||||
.ge-layer-thumb canvas {
|
||||
display: block;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.ge-layers-merge-menu[hidden] { display: none; }
|
||||
.ge-layer-item:not(.active) .ge-layer-inline-thumb { opacity: 0.86; }
|
||||
.ge-layer-opacity-row .ge-layer-lock-btn { flex: 0 0 24px; width: 24px; }
|
||||
.ge-layers-list > .ge-layer-item:not(.ge-adj-sub-item):not(.ge-mask-sub-item) > .ge-layer-controls {
|
||||
grid-column: 1 / -1;
|
||||
grid-row: 3;
|
||||
}
|
||||
.ge-layer-item.grouped { padding-left: calc(20px + (var(--layer-group-depth, 1) - 1) * 14px); }
|
||||
.ge-layer-vis.visible:hover { opacity: 1; }
|
||||
.ge-layer-item.clipped .ge-layer-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
/* Shrink the slider thumb — the native default is ~16-20 px which looks
|
||||
massive next to a 3 px track in the compact layer row. */
|
||||
.ge-layer-opacity::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
background: var(--red);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
/* Match the track: shrink only after a 0.5s grace period. */
|
||||
transition: width 0.12s ease 0.5s, height 0.12s ease 0.5s;
|
||||
}
|
||||
.ge-layer-opacity::-moz-range-thumb {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
background: var(--red);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: width 0.12s ease 0.5s, height 0.12s ease 0.5s;
|
||||
}
|
||||
.ge-layer-opacity:hover::-webkit-slider-thumb,
|
||||
.ge-layer-opacity:active::-webkit-slider-thumb,
|
||||
.ge-layer-opacity.dragging::-webkit-slider-thumb,
|
||||
.ge-layer-opacity:hover::-moz-range-thumb,
|
||||
.ge-layer-opacity:active::-moz-range-thumb,
|
||||
.ge-layer-opacity.dragging::-moz-range-thumb {
|
||||
transition: width 0.12s ease 0s, height 0.12s ease 0s;
|
||||
}
|
||||
.ge-layer-controls .ge-mask-density {
|
||||
width: 26px;
|
||||
min-width: 24px;
|
||||
height: 4px;
|
||||
flex: 0 0 26px;
|
||||
align-self: center;
|
||||
}
|
||||
.ge-mask-properties > summary {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
list-style: none;
|
||||
cursor: pointer;
|
||||
color: var(--fg);
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.ge-mask-properties > summary::-webkit-details-marker { display: none; }
|
||||
.ge-mask-sub-item:has(.ge-mask-properties[open]) {
|
||||
position: relative;
|
||||
z-index: 50;
|
||||
}
|
||||
.ge-mask-property-row output { text-align: right; opacity: .7; }
|
||||
.ge-layers-list > .ge-layer-item:not(.ge-adj-sub-item):not(.ge-mask-sub-item) > .ge-layer-controls,
|
||||
.ge-layers-list > .ge-layer-group-row > .ge-layer-controls {
|
||||
display: none;
|
||||
}
|
||||
.ge-layer-tools[hidden] { display: none; }
|
||||
.ge-layer-tools .ge-layer-btn { min-width: 26px; }
|
||||
@media (max-width: 700px) {
|
||||
/* Preserve the compact grip while giving long-press reorder a usable
|
||||
touch target. */
|
||||
.ge-layer-drag {
|
||||
width: 28px;
|
||||
min-height: 30px;
|
||||
margin-left: -6px;
|
||||
margin-right: -4px;
|
||||
}
|
||||
}
|
||||
.ge-layer-align-menu .ge-layer-lock-option { grid-template-columns: minmax(0, 1fr); }
|
||||
.ge-crop-apply input[type="number"]::-webkit-outer-spin-button,
|
||||
.ge-crop-apply input[type="number"]::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
.ge-brush-cursor::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%; left: 50%;
|
||||
width: 3px; height: 3px;
|
||||
margin: -1.5px 0 0 -1.5px;
|
||||
background: rgba(255,255,255,0.8);
|
||||
border-radius: 50%;
|
||||
}
|
||||
/* Marks remain solid so they pop through the otherwise-invisible
|
||||
overlay; outlined extra-bright for the currently-focused one. */
|
||||
body.doc-find-active mark.doc-find-mark {
|
||||
background: var(--accent) !important;
|
||||
color: var(--bg) !important;
|
||||
}
|
||||
body.doc-find-active mark.doc-find-mark.current {
|
||||
background: var(--accent) !important;
|
||||
box-shadow: 0 0 0 2px var(--fg) !important;
|
||||
outline: 1px solid var(--fg) !important;
|
||||
}
|
||||
.doc-email-header:not(.doc-email-header-collapsed) .doc-email-collapse-summary {
|
||||
opacity: 0.45;
|
||||
}
|
||||
.doc-email-header.doc-email-header-collapsed {
|
||||
gap: 0;
|
||||
padding-top: 5px;
|
||||
padding-bottom: 5px;
|
||||
}
|
||||
.doc-email-header.doc-email-header-collapsed .doc-email-fields {
|
||||
display: none;
|
||||
}
|
||||
/* Documents footer — X + Undo stay on the LEFT, Copy/Export group pushed
|
||||
to the RIGHT. Email composer keeps its original flex-end layout. */
|
||||
#doc-actions-footer {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.doc-save-button[data-save-state="dirty"] .doc-save-state-dirty,
|
||||
.doc-save-button[data-save-state="saving"] .doc-save-state-saving,
|
||||
.doc-save-button[data-save-state="saved"] .doc-save-state-saved,
|
||||
.doc-save-button[data-save-state="error"] .doc-save-state-error { display: block; }
|
||||
.doc-save-button[data-save-state="saving"] .doc-save-state-saving {
|
||||
animation: doc-save-spin 800ms linear infinite;
|
||||
}
|
||||
@keyframes doc-save-spin { to { transform: rotate(360deg); } }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.doc-save-button[data-save-state="saving"] .doc-save-state-saving { animation: none; }
|
||||
}
|
||||
.doc-stats-popover[hidden] { display: none; }
|
||||
.doc-stats-row strong {
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.doc-email-richbody:empty::before {
|
||||
content: "Write your email\2026";
|
||||
opacity: 0.4;
|
||||
pointer-events: none;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.doc-rich-empty-import {
|
||||
top: 106px;
|
||||
}
|
||||
}
|
||||
.doc-email-richbody.richtext-mode:empty::before {
|
||||
content: "Start writing\2026";
|
||||
}
|
||||
.doc-email-richbody.richtext-mode ul,
|
||||
.doc-email-richbody.richtext-mode ol {
|
||||
padding-left: 1.75em;
|
||||
}
|
||||
.doc-email-richbody.richtext-mode ul:not(.rich-checklist) > li,
|
||||
.doc-email-richbody.richtext-mode ol > li {
|
||||
padding-inline-start: 0.2em;
|
||||
}
|
||||
.doc-email-richbody.richtext-mode pre code {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
}
|
||||
.doc-email-richbody figure.richtext-image .richtext-image-caption {
|
||||
margin-top: 6px;
|
||||
max-width: 100%;
|
||||
color: color-mix(in srgb, var(--fg) 68%, transparent);
|
||||
font-size: 0.84em;
|
||||
line-height: 1.4;
|
||||
text-align: center;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.doc-email-richbody a { color: var(--accent, #4af); }
|
||||
.doc-email-richbody blockquote {
|
||||
margin: 6px 0; padding-left: 10px;
|
||||
border-left: 3px solid var(--border); opacity: 0.8;
|
||||
}
|
||||
.doc-email-richbody p { margin: 0.5em 0; }
|
||||
#doc-email-more-btn svg { opacity: 0.8; }
|
||||
@media (min-width: 769px) {
|
||||
#doc-email-header #doc-email-collapse-btn.doc-email-collapse-btn {
|
||||
display: none !important;
|
||||
}
|
||||
#doc-email-header.doc-email-header-collapsed .doc-email-fields {
|
||||
display: flex !important;
|
||||
}
|
||||
}
|
||||
.doclib-section-title-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
margin-right: 6px;
|
||||
color: var(--accent, var(--red));
|
||||
vertical-align: -2px;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -247,6 +247,13 @@
|
||||
<link rel="preload" as="font" type="font/woff2" crossorigin href="/static/fonts/FiraCode-Regular.woff2">
|
||||
<link rel="preload" as="font" type="font/woff2" crossorigin href="/static/fonts/FiraCode-SemiBold.woff2">
|
||||
<link rel="stylesheet" href="/static/style.css?v=20260921chatgptusage2">
|
||||
<!-- Panel stylesheets split out of style.css for maintainability. They are
|
||||
eager and their order is load-bearing: the cascade is the concatenation of
|
||||
these files in this order, so they must stay in this order here and in the
|
||||
PRECACHE list in sw.js. None of them is lazy-loaded. -->
|
||||
<link rel="stylesheet" href="/static/css/documents-gallery-editor.css?v=20260923csssplit1">
|
||||
<link rel="stylesheet" href="/static/css/email-calendar-notes-tasks.css?v=20260923csssplit2">
|
||||
<link rel="stylesheet" href="/static/css/cookbook-research-memory-settings.css?v=20260923csssplit3">
|
||||
<link rel="modulepreload" href="/static/app.js?v=20260921chatgptusage2">
|
||||
<link rel="modulepreload" href="/static/js/chat.js?v=20260917toolttft1">
|
||||
<link rel="modulepreload" href="/static/js/ui.js?v=20260916largetoolscroll1">
|
||||
|
||||
+7
-3902
File diff suppressed because it is too large
Load Diff
+4
-1
@@ -7,7 +7,7 @@
|
||||
// - Other static assets (images/fonts/libs): cache-first with bg refresh.
|
||||
// - API / non-GET: never cached.
|
||||
// Bump CACHE_NAME whenever the precache list or SW logic changes.
|
||||
const CACHE_NAME = 'odysseus-v651-chatgpt-usage-collapse';
|
||||
const CACHE_NAME = 'odysseus-v654-css-split-3';
|
||||
|
||||
// KaTeX resolves these from its own stylesheet, so caching the CSS without them
|
||||
// gives offline math fallback glyphs instead of proper typesetting.
|
||||
@@ -40,6 +40,9 @@ const KATEX_FONTS = [
|
||||
const PRECACHE = [
|
||||
'/',
|
||||
'/static/style.css?v=20260921chatgptusage2',
|
||||
'/static/css/documents-gallery-editor.css?v=20260923csssplit1',
|
||||
'/static/css/email-calendar-notes-tasks.css?v=20260923csssplit2',
|
||||
'/static/css/cookbook-research-memory-settings.css?v=20260923csssplit3',
|
||||
'/static/app.js?v=20260921chatgptusage2',
|
||||
'/static/js/storage.js',
|
||||
'/static/js/appConfig.js',
|
||||
|
||||
@@ -172,6 +172,23 @@ The helpers below live under `tests/helpers/`. They exist to remove repeated
|
||||
boilerplate that already appeared across multiple tests. Reach for one only when
|
||||
your test matches its intended use; do not stretch a helper to cover a new case.
|
||||
|
||||
### `tests.helpers.stylesheets.app_css`
|
||||
|
||||
Use when a test asserts on a CSS rule.
|
||||
|
||||
- Returns every app stylesheet concatenated in the order `static/index.html`
|
||||
loads them, which is the order the cascade actually has.
|
||||
- Panel styles no longer all live in `static/style.css`; reading that file
|
||||
alone ties the test to whichever file a rule sits in today, so it goes red
|
||||
when a rule moves without the rendered page changing.
|
||||
- `stylesheet_paths()` and `stylesheet_urls()` are there when a test needs the
|
||||
files or the request URLs rather than their contents.
|
||||
`stylesheet_link_tags()` returns the `<link>` markup for a synthetic page
|
||||
driven through Playwright, so it gets the whole cascade instead of only
|
||||
`style.css`.
|
||||
- All of them fail loudly if `index.html` links a stylesheet that is missing.
|
||||
- Not for vendored CSS under `static/lib/`, which they deliberately skip.
|
||||
|
||||
### `tests.helpers.cli_loader.load_script`
|
||||
|
||||
Use when a test needs to import a script under `scripts/` without repeating
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Read the app's CSS the way the browser does.
|
||||
|
||||
``static/style.css`` no longer holds every rule: panel styles live in separate
|
||||
files that ``static/index.html`` loads eagerly, in a fixed order, right after
|
||||
it. The cascade is the concatenation of those files in that order.
|
||||
|
||||
A test that asserts on a rule must therefore look at all of them. Reading
|
||||
``static/style.css`` alone ties the test to whichever file a rule happens to
|
||||
sit in today, so it goes red the next time a rule moves without anything about
|
||||
the rendered page having changed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
_STATIC = Path(__file__).resolve().parents[2] / "static"
|
||||
_INDEX = _STATIC / "index.html"
|
||||
|
||||
# Only same-origin app stylesheets. Vendored <link>s under static/lib are not
|
||||
# part of the cascade these tests reason about.
|
||||
_LINK = re.compile(
|
||||
r"""<link\b[^>]*\brel\s*=\s*["']stylesheet["'][^>]*\bhref\s*=\s*["']/static/([^"'?]+)([^"']*)["']""",
|
||||
re.I,
|
||||
)
|
||||
|
||||
|
||||
def _entries() -> list[tuple[Path, str]]:
|
||||
html = _INDEX.read_text(encoding="utf-8")
|
||||
out = []
|
||||
for m in _LINK.finditer(html):
|
||||
rel, query = m.group(1), m.group(2)
|
||||
if rel.startswith("lib/"):
|
||||
continue
|
||||
out.append((_STATIC / rel, "/static/" + rel + query))
|
||||
if not out:
|
||||
raise AssertionError(f"no app stylesheet <link> tags found in {_INDEX}")
|
||||
missing = [p for p, _u in out if not p.is_file()]
|
||||
if missing:
|
||||
raise AssertionError(f"index.html links stylesheets that do not exist: {missing}")
|
||||
return out
|
||||
|
||||
|
||||
def stylesheet_paths() -> list[Path]:
|
||||
"""Every app stylesheet on disk, in the order index.html loads it."""
|
||||
return [p for p, _u in _entries()]
|
||||
|
||||
|
||||
def stylesheet_urls() -> list[str]:
|
||||
"""The same stylesheets as request URLs, query string included."""
|
||||
return [u for _p, u in _entries()]
|
||||
|
||||
|
||||
def stylesheet_link_tags() -> str:
|
||||
"""The <link> tags to drop into a synthetic page so it gets the whole
|
||||
cascade, not just style.css."""
|
||||
return "".join(f'<link rel="stylesheet" href="{u}">' for u in stylesheet_urls())
|
||||
|
||||
|
||||
def app_css() -> str:
|
||||
"""The whole cascade as one string, in load order."""
|
||||
return "\n".join(p.read_text(encoding="utf-8") for p in stylesheet_paths())
|
||||
@@ -1,6 +1,8 @@
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
|
||||
from tests.helpers.stylesheets import app_css
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
@@ -80,7 +82,7 @@ def test_email_filter_menu_has_context_title() -> None:
|
||||
|
||||
def test_email_setting_toggles_render_neutral_disabled_state() -> None:
|
||||
source = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
|
||||
style = (ROOT / "static/style.css").read_text(encoding="utf-8")
|
||||
style = app_css()
|
||||
assert 'email-settings-auto-reply-section' in source
|
||||
assert 'email-settings-display-enabled-state' in source
|
||||
assert 'stateLabel = section?.querySelector' in source
|
||||
@@ -100,7 +102,7 @@ def test_email_date_headers_mark_unexpected_timeline_gaps() -> None:
|
||||
assert "email-date-gap-break" in source
|
||||
assert "gapDays > 90 && gapDays > timelineGapThreshold" in source
|
||||
|
||||
style = (ROOT / "static/style.css").read_text(encoding="utf-8")
|
||||
style = app_css()
|
||||
assert ".date-section-header.email-date-gap-break" in style
|
||||
|
||||
|
||||
@@ -122,7 +124,7 @@ def test_email_filters_and_card_favorite_toggle_are_wired() -> None:
|
||||
assert "const typedFilter = _exactTypedFilterSuggestion(v);" in source
|
||||
assert "_acceptSuggestion(typedFilter);" in source
|
||||
|
||||
style = (ROOT / "static/style.css").read_text(encoding="utf-8")
|
||||
style = app_css()
|
||||
favorite_start = style.index(".email-card-favorite {")
|
||||
favorite_end = style.index("}", favorite_start) + 1
|
||||
assert "top: -3px;" in style[favorite_start:favorite_end]
|
||||
@@ -154,7 +156,7 @@ def test_email_auto_reply_syncs_one_calendar_event_per_account() -> None:
|
||||
|
||||
def test_email_settings_show_away_account_and_compact_display_controls() -> None:
|
||||
source = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
|
||||
style = (ROOT / "static/style.css").read_text(encoding="utf-8")
|
||||
style = app_css()
|
||||
assert 'email-account-away-label">(AWAY)</span>' in source
|
||||
assert 'id="email-lib-auto-reply-badge"' in source
|
||||
assert ">Show Email Tags</span>" in source
|
||||
@@ -227,7 +229,7 @@ def test_email_clean_always_forces_a_fresh_unsubscribe_scan() -> None:
|
||||
|
||||
def test_unsubscribe_duplicate_badge_is_lowered() -> None:
|
||||
frontend = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
|
||||
stylesheet = (ROOT / "static/style.css").read_text(encoding="utf-8")
|
||||
stylesheet = app_css()
|
||||
assert "email-unsub-duplicate-badge" in frontend
|
||||
start = stylesheet.index(".email-unsub-duplicate-badge {")
|
||||
assert "top: 2px;" in stylesheet[start:stylesheet.index("}", start) + 1]
|
||||
@@ -235,7 +237,7 @@ def test_unsubscribe_duplicate_badge_is_lowered() -> None:
|
||||
|
||||
def test_unsubscribe_scan_status_sits_before_clean_action() -> None:
|
||||
frontend = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
|
||||
stylesheet = (ROOT / "static/style.css").read_text(encoding="utf-8")
|
||||
stylesheet = app_css()
|
||||
start = frontend.index("function _emailCleanupSettingsHtml")
|
||||
end = frontend.index("function _emailDisplaySettingsHtml", start)
|
||||
cleanup = frontend[start:end]
|
||||
@@ -291,7 +293,7 @@ def test_browser_agent_unsubscribe_cleans_sender_after_positive_confirmation() -
|
||||
|
||||
|
||||
def test_auto_unsubscribe_all_is_visibly_taller_than_toolbar_buttons() -> None:
|
||||
source = (ROOT / "static/style.css").read_text(encoding="utf-8")
|
||||
source = app_css()
|
||||
start = source.index(".email-unsub-auto-safe-btn {")
|
||||
assert "height: 29px;" in source[start:source.index("}", start) + 1]
|
||||
|
||||
@@ -330,7 +332,7 @@ def test_unsubscribe_review_marks_handled_cards_and_offers_scan_further() -> Non
|
||||
|
||||
def test_unsubscribe_review_can_ignore_a_candidate_without_deleting_it() -> None:
|
||||
source = (ROOT / "static" / "js" / "emailLibrary.js").read_text()
|
||||
styles = (ROOT / "static" / "style.css").read_text()
|
||||
styles = app_css()
|
||||
assert "email-unsub-ignore-btn" in source
|
||||
assert "_rememberUnsubscribeIgnored(c)" in source
|
||||
assert "Ignore this unsubscribe candidate" in source
|
||||
@@ -339,7 +341,7 @@ def test_unsubscribe_review_can_ignore_a_candidate_without_deleting_it() -> None
|
||||
|
||||
def test_email_settings_sections_use_static_headers() -> None:
|
||||
source = (ROOT / "static" / "js" / "emailLibrary.js").read_text()
|
||||
styles = (ROOT / "static" / "style.css").read_text()
|
||||
styles = app_css()
|
||||
assert 'class="email-unsub-accent-icon"' in source
|
||||
assert 'M12 0L14.59 8.41' in source
|
||||
assert 'Scanning ${_esc(scanFolderLabel)} headers…' in source
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
from tests.helpers.stylesheets import app_css
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
@@ -25,7 +27,7 @@ def test_successful_calendar_tool_output_is_suppressed_in_live_and_saved_rendere
|
||||
|
||||
def test_calendar_chat_event_links_fetch_uid_and_show_title_time():
|
||||
calendar_src = (ROOT / "static/js/calendar.js").read_text()
|
||||
style_src = (ROOT / "static/style.css").read_text()
|
||||
style_src = app_css()
|
||||
routes_src = (ROOT / "routes/calendar_routes.py").read_text()
|
||||
app_src = (ROOT / "static/app.js").read_text()
|
||||
renderer_src = (ROOT / "static/js/chatRenderer.js").read_text()
|
||||
|
||||
@@ -1,13 +1,25 @@
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
from tests.helpers.stylesheets import app_css
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def _occurrences(text, needle):
|
||||
"""Every index of needle, so an assertion does not depend on which copy of
|
||||
a selector the cascade happens to put first."""
|
||||
out, i = [], text.find(needle)
|
||||
while i != -1:
|
||||
out.append(i)
|
||||
i = text.find(needle, i + 1)
|
||||
return out
|
||||
|
||||
|
||||
def test_calendar_week_view_has_overlap_lanes_and_live_ruler_hooks():
|
||||
calendar_src = (ROOT / "static/js/calendar.js").read_text()
|
||||
style_src = (ROOT / "static/style.css").read_text()
|
||||
style_src = app_css()
|
||||
|
||||
assert "function _wkLayoutTimedEvents" in calendar_src
|
||||
assert "--lane:${lane};--lane-count:${laneCount}" in calendar_src
|
||||
@@ -20,7 +32,7 @@ def test_calendar_week_view_has_overlap_lanes_and_live_ruler_hooks():
|
||||
|
||||
def test_crowded_week_events_expand_left_to_reveal_full_title_on_hover():
|
||||
calendar_src = (ROOT / "static/js/calendar.js").read_text()
|
||||
style_src = (ROOT / "static/style.css").read_text()
|
||||
style_src = app_css()
|
||||
|
||||
assert "const crowdedClass = laneCount > 1 ? ' cal-wk-block-crowded' : '';" in calendar_src
|
||||
assert "--lane-right:${laneRight}%" in calendar_src
|
||||
@@ -61,7 +73,7 @@ def test_crowded_week_events_expand_left_to_reveal_full_title_on_hover():
|
||||
|
||||
def test_mobile_truncated_week_event_expands_before_opening_editor():
|
||||
calendar_src = (ROOT / "static/js/calendar.js").read_text()
|
||||
style_src = (ROOT / "static/style.css").read_text()
|
||||
style_src = app_css()
|
||||
|
||||
click_idx = calendar_src.index("body.querySelectorAll('.cal-wk-block, .cal-wk-allday-event')")
|
||||
edit_idx = calendar_src.index("if (ev) _showEventForm(ev);", click_idx)
|
||||
@@ -78,7 +90,7 @@ def test_mobile_truncated_week_event_expands_before_opening_editor():
|
||||
|
||||
def test_mobile_empty_week_slot_selects_before_opening_new_event():
|
||||
calendar_src = (ROOT / "static/js/calendar.js").read_text()
|
||||
style_src = (ROOT / "static/style.css").read_text()
|
||||
style_src = app_css()
|
||||
|
||||
assert "let _selectedWeekSlot = null;" in calendar_src
|
||||
assert "function _paintSelectedWeekSlot(body)" in calendar_src
|
||||
@@ -100,7 +112,7 @@ def test_mobile_empty_week_slot_selects_before_opening_new_event():
|
||||
|
||||
def test_week_view_hints_when_current_time_is_below_the_visible_field():
|
||||
calendar_src = (ROOT / "static/js/calendar.js").read_text()
|
||||
style_src = (ROOT / "static/style.css").read_text()
|
||||
style_src = app_css()
|
||||
|
||||
assert "function _updateWeekNowBelowHint(wrap)" in calendar_src
|
||||
assert "nowRect.top > wrapRect.bottom - 2" in calendar_src
|
||||
@@ -115,7 +127,7 @@ def test_week_view_hints_when_current_time_is_below_the_visible_field():
|
||||
|
||||
def test_mobile_week_scroll_has_resisted_edge_pull_feedback():
|
||||
calendar_src = (ROOT / "static/js/calendar.js").read_text()
|
||||
style_src = (ROOT / "static/style.css").read_text()
|
||||
style_src = app_css()
|
||||
|
||||
assert "pullStartedAtTop = _wrap.scrollTop <= 1;" in calendar_src
|
||||
assert "pullStartedAtBottom = _wrap.scrollTop >= maxScroll - 1;" in calendar_src
|
||||
@@ -143,7 +155,7 @@ def test_calendar_view_change_recovers_a_fully_open_day_drawer():
|
||||
|
||||
def test_calendar_month_and_agenda_have_visual_depth_hooks():
|
||||
calendar_src = (ROOT / "static/js/calendar.js").read_text()
|
||||
style_src = (ROOT / "static/style.css").read_text()
|
||||
style_src = app_css()
|
||||
|
||||
assert "cal-weekend" in calendar_src
|
||||
assert "cal-empty-day" in calendar_src
|
||||
@@ -156,7 +168,7 @@ def test_calendar_month_and_agenda_have_visual_depth_hooks():
|
||||
|
||||
def test_calendar_event_cards_show_compact_source_badges():
|
||||
calendar_src = (ROOT / "static/js/calendar.js").read_text()
|
||||
style_src = (ROOT / "static/style.css").read_text()
|
||||
style_src = app_css()
|
||||
|
||||
assert "function _eventSourceHtml" in calendar_src
|
||||
assert "${_eventSourceHtml(ev)}" in calendar_src
|
||||
@@ -166,7 +178,7 @@ def test_calendar_event_cards_show_compact_source_badges():
|
||||
|
||||
def test_calendar_toolbar_previous_next_arrows_are_mobile_only():
|
||||
calendar_src = (ROOT / "static/js/calendar.js").read_text()
|
||||
style_src = (ROOT / "static/style.css").read_text()
|
||||
style_src = app_css()
|
||||
|
||||
assert 'class="cal-nav cal-toolbar-arrow" id="cal-prev"' in calendar_src
|
||||
assert 'class="cal-nav cal-toolbar-arrow" id="cal-next"' in calendar_src
|
||||
@@ -181,7 +193,7 @@ def test_calendar_toolbar_previous_next_arrows_are_mobile_only():
|
||||
|
||||
def test_calendar_side_arrows_center_against_calendar_pane():
|
||||
calendar_src = (ROOT / "static/js/calendar.js").read_text()
|
||||
style_src = (ROOT / "static/style.css").read_text()
|
||||
style_src = app_css()
|
||||
|
||||
assert "function _alignSideNavToCalendar()" in calendar_src
|
||||
assert "paneRect.top - contentRect.top + (paneRect.height / 2)" in calendar_src
|
||||
@@ -191,7 +203,7 @@ def test_calendar_side_arrows_center_against_calendar_pane():
|
||||
|
||||
def test_calendar_splitter_double_click_snaps_to_nearest_pane_then_toggles():
|
||||
calendar_src = (ROOT / "static/js/calendar.js").read_text()
|
||||
style_src = (ROOT / "static/style.css").read_text()
|
||||
style_src = app_css()
|
||||
|
||||
assert "detailH >= calendarH ? 'events' : 'calendar'" in calendar_src
|
||||
assert "splitter.addEventListener('dblclick', () => {" in calendar_src
|
||||
@@ -302,7 +314,7 @@ def test_calendar_settings_are_close_only_and_color_the_name_field():
|
||||
assert name_idx < color_idx
|
||||
assert "nameInput.style.borderColor = colorInput.value" in calendar_src
|
||||
assert "nameInput.style.backgroundColor = colorInput.value" not in calendar_src
|
||||
style_src = (ROOT / "static/style.css").read_text()
|
||||
style_src = app_css()
|
||||
toggle_idx = style_src.index(".cal-week-start-toggle")
|
||||
button_idx = style_src.index(".cal-week-start-btn", toggle_idx)
|
||||
assert "padding: 0;" in style_src[toggle_idx:button_idx]
|
||||
@@ -312,7 +324,7 @@ def test_calendar_settings_are_close_only_and_color_the_name_field():
|
||||
|
||||
def test_calendar_settings_actions_sync_error_and_escape_behavior():
|
||||
calendar_src = (ROOT / "static/js/calendar.js").read_text()
|
||||
style_src = (ROOT / "static/style.css").read_text()
|
||||
style_src = app_css()
|
||||
|
||||
assert calendar_src.count('class="cal-settings-actions"') >= 4
|
||||
assert "justify-content: flex-end;" in style_src[style_src.index(".cal-settings-actions"):]
|
||||
@@ -331,7 +343,7 @@ def test_calendar_settings_actions_sync_error_and_escape_behavior():
|
||||
|
||||
def test_calendar_hides_navigation_in_agenda_and_event_forms():
|
||||
calendar_src = (ROOT / "static/js/calendar.js").read_text()
|
||||
style_src = (ROOT / "static/style.css").read_text()
|
||||
style_src = app_css()
|
||||
|
||||
assert "_modal?.classList.toggle('cal-navigation-hidden', _view === 'agenda')" in calendar_src
|
||||
assert "_modal?.classList.add('cal-navigation-hidden')" in calendar_src
|
||||
@@ -353,7 +365,7 @@ def test_calendar_event_form_escape_cancels_before_modal_close():
|
||||
|
||||
|
||||
def test_calendar_from_and_to_month_day_use_theme_highlight():
|
||||
style_src = (ROOT / "static/style.css").read_text()
|
||||
style_src = app_css()
|
||||
|
||||
month_idx = style_src.index('input[type="date"]::-webkit-datetime-edit-month-field')
|
||||
day_idx = style_src.index('input[type="date"]::-webkit-datetime-edit-day-field', month_idx)
|
||||
@@ -364,7 +376,7 @@ def test_calendar_from_and_to_month_day_use_theme_highlight():
|
||||
|
||||
def test_calendar_location_hint_uses_accent_map_pin():
|
||||
calendar_src = (ROOT / "static/js/calendar.js").read_text()
|
||||
style_src = (ROOT / "static/style.css").read_text()
|
||||
style_src = app_css()
|
||||
|
||||
assert 'class="cal-loc-hint"' in calendar_src
|
||||
assert '<span>Location</span>' in calendar_src
|
||||
@@ -382,7 +394,7 @@ def test_calendar_tag_order_prioritizes_personal_work_travel_admin():
|
||||
|
||||
def test_long_week_events_keep_their_label_visible_while_scrolling():
|
||||
calendar_src = (ROOT / "static/js/calendar.js").read_text()
|
||||
style_src = (ROOT / "static/style.css").read_text()
|
||||
style_src = app_css()
|
||||
|
||||
assert 'class="cal-wk-block-label"' in calendar_src
|
||||
label_idx = style_src.index(".cal-wk-block-label")
|
||||
@@ -393,8 +405,12 @@ def test_long_week_events_keep_their_label_visible_while_scrolling():
|
||||
block_idx = style_src.index(".cal-wk-block {")
|
||||
assert "overflow: clip;" in style_src[block_idx:label_idx]
|
||||
head_idx = style_src.index(".cal-wk-col-head {")
|
||||
allday_idx = style_src.rindex(
|
||||
".cal-wk-allday {", 0, style_src.index(".cal-wk-allday::-webkit-scrollbar")
|
||||
# .cal-wk-allday and its scrollbar rule may now sit in different
|
||||
# stylesheets, so anchoring on the scrollbar rule's offset no longer finds
|
||||
# the block. Take whichever .cal-wk-allday block carries the z-index.
|
||||
allday_idx = next(
|
||||
i for i in _occurrences(style_src, ".cal-wk-allday {")
|
||||
if "z-index: 39;" in style_src[i:i + 500]
|
||||
)
|
||||
now_idx = style_src.index(".cal-wk-now {")
|
||||
assert "z-index: 40;" in style_src[head_idx:head_idx + 500]
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from pathlib import Path
|
||||
|
||||
from tests.helpers.stylesheets import app_css
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
@@ -302,7 +304,7 @@ def test_dependency_status_cache_is_not_fragmented_by_unused_model_hint():
|
||||
def test_model_lists_fade_after_complete_rows_are_painted():
|
||||
hwfit = _read("static/js/cookbook-hwfit.js")
|
||||
cookbook = _read("static/js/cookbook.js")
|
||||
style = _read("static/style.css")
|
||||
style = app_css()
|
||||
|
||||
assert "cookbook-model-list-fade" in hwfit
|
||||
assert "cookbook-model-list-fade" in cookbook
|
||||
@@ -314,7 +316,7 @@ def test_model_lists_fade_after_complete_rows_are_painted():
|
||||
|
||||
def test_dependency_panel_has_stable_categories_and_mobile_build_action():
|
||||
cookbook = _read("static/js/cookbook.js")
|
||||
style = _read("static/style.css")
|
||||
style = app_css()
|
||||
|
||||
assert "const _depCategoryOrder = ['System', 'Tools', 'LLM', 'Image'" in cookbook
|
||||
assert "const _orderedDepCategories = (byCat)" in cookbook
|
||||
@@ -337,7 +339,7 @@ def test_direct_download_auto_fold_requires_deliberate_boundary_gesture():
|
||||
|
||||
|
||||
def test_launch_command_box_uses_accent_focus_glow():
|
||||
style = _read("static/style.css")
|
||||
style = app_css()
|
||||
assert ".hwfit-serve-cmd-details[open] .hwfit-serve-cmd" in style
|
||||
assert "box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent" in style
|
||||
|
||||
@@ -345,7 +347,7 @@ def test_launch_command_box_uses_accent_focus_glow():
|
||||
def test_server_settings_color_picker_uses_dot_trigger():
|
||||
cookbook = _read("static/js/cookbook.js")
|
||||
hwfit = _read("static/js/cookbook-hwfit.js")
|
||||
style = _read("static/style.css")
|
||||
style = app_css()
|
||||
assert 'aria-label="Change server color (currently ${esc(selectedColorLabel)})"' in cookbook
|
||||
assert "btn.setAttribute('aria-label', `Change server color (currently ${label})`)" in hwfit
|
||||
assert ".cookbook-server-row .cookbook-srv-color-dot" in style
|
||||
@@ -357,7 +359,7 @@ def test_server_settings_color_picker_uses_dot_trigger():
|
||||
def test_default_server_matches_exclusive_model_directory_selector():
|
||||
cookbook = _read("static/js/cookbook.js")
|
||||
hwfit = _read("static/js/cookbook-hwfit.js")
|
||||
style = _read("static/style.css")
|
||||
style = app_css()
|
||||
assert "const icon = active ? _MODELDIR_CHECK_ON : _MODELDIR_CHECK_OFF;" in cookbook
|
||||
assert "_envState.defaultServer = key;" in hwfit
|
||||
assert "Toggle off if it's already the default" not in hwfit
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from tests.helpers.stylesheets import app_css
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
COOKBOOK = (ROOT / "static/js/cookbook.js").read_text(encoding="utf-8")
|
||||
@@ -18,7 +20,7 @@ def test_trending_models_expose_persistent_official_only_switch():
|
||||
|
||||
|
||||
def test_trending_models_list_stays_within_the_cookbook_window():
|
||||
style = (ROOT / "static/style.css").read_text(encoding="utf-8")
|
||||
style = app_css()
|
||||
|
||||
rule = style[style.index("#cookbook-hf-latest-list {"):style.index("#cookbook-hf-latest-list {") + 220]
|
||||
assert "max-height: min(52vh, 480px);" in rule
|
||||
@@ -34,7 +36,7 @@ def test_trending_endpoint_applies_first_party_namespace_filter():
|
||||
|
||||
|
||||
def test_official_only_toggle_fits_narrow_download_toolbar():
|
||||
style = (ROOT / "static/style.css").read_text(encoding="utf-8")
|
||||
style = app_css()
|
||||
start = style.rindex(".cookbook-official-filter {")
|
||||
rule = style[start:style.index("}", start)]
|
||||
assert "flex: 0 1 auto" in rule
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
from tests.helpers.stylesheets import app_css
|
||||
|
||||
CSS = Path("static/style.css").read_text()
|
||||
|
||||
CSS = app_css()
|
||||
|
||||
|
||||
def rule_for(selector: str) -> str:
|
||||
|
||||
@@ -5,10 +5,12 @@ import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from tests.helpers.stylesheets import app_css
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
|
||||
STYLE = (ROOT / "static/style.css").read_text(encoding="utf-8")
|
||||
STYLE = app_css()
|
||||
|
||||
|
||||
def test_font_and_color_controls_use_shared_components():
|
||||
|
||||
@@ -4,10 +4,12 @@ import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from tests.helpers.stylesheets import app_css
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
|
||||
STYLE = (ROOT / "static/style.css").read_text(encoding="utf-8")
|
||||
STYLE = app_css()
|
||||
|
||||
|
||||
def test_image_caption_uses_semantic_figure_and_structured_export_paths():
|
||||
|
||||
@@ -4,10 +4,12 @@ import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from tests.helpers.stylesheets import app_css
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
|
||||
STYLE = (ROOT / "static/style.css").read_text(encoding="utf-8")
|
||||
STYLE = app_css()
|
||||
|
||||
|
||||
def test_input_rules_are_scoped_to_plain_blocks():
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from tests.helpers.stylesheets import app_css
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
|
||||
STYLE = (ROOT / "static/style.css").read_text(encoding="utf-8")
|
||||
STYLE = app_css()
|
||||
|
||||
|
||||
def test_rich_text_toolbar_exposes_document_formatting_controls():
|
||||
|
||||
@@ -4,10 +4,12 @@ import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from tests.helpers.stylesheets import app_css
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
|
||||
STYLE = (ROOT / "static/style.css").read_text(encoding="utf-8")
|
||||
STYLE = app_css()
|
||||
|
||||
|
||||
def _run_stats(expression: str):
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
from pathlib import Path
|
||||
|
||||
from tests.helpers.stylesheets import app_css
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def test_gallery_detail_uses_fixed_media_stage_and_arrow_lanes():
|
||||
css = (ROOT / "static/style.css").read_text(encoding="utf-8")
|
||||
css = app_css()
|
||||
image_rule = css[css.index(".gallery-detail-image {"):]
|
||||
image_rule = image_rule[:image_rule.index("}")]
|
||||
|
||||
@@ -17,7 +19,7 @@ def test_gallery_detail_uses_fixed_media_stage_and_arrow_lanes():
|
||||
|
||||
|
||||
def test_gallery_media_maximizes_inside_stage_without_resizing_it():
|
||||
css = (ROOT / "static/style.css").read_text(encoding="utf-8")
|
||||
css = app_css()
|
||||
|
||||
assert css.count("max-height: 100%") >= 2
|
||||
assert "object-fit: contain" in css
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
from pathlib import Path
|
||||
|
||||
from tests.helpers.stylesheets import app_css
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def test_gallery_selection_dot_has_rendered_dimensions():
|
||||
css = (ROOT / "static/style.css").read_text(encoding="utf-8")
|
||||
css = app_css()
|
||||
rule = css[css.index(".gallery-select-dot {"):]
|
||||
rule = rule[:rule.index("}")]
|
||||
|
||||
@@ -15,7 +17,7 @@ def test_gallery_selection_dot_has_rendered_dimensions():
|
||||
|
||||
|
||||
def test_gallery_selection_highlight_is_painted_above_thumbnail():
|
||||
css = (ROOT / "static/style.css").read_text(encoding="utf-8")
|
||||
css = app_css()
|
||||
selector = ".gallery-card:has(.gallery-select-dot.selected)::after"
|
||||
rule = css[css.index(selector):]
|
||||
rule = rule[:rule.index("}")]
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
from tests.helpers.stylesheets import app_css
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
@@ -20,7 +22,7 @@ def test_gallery_exposes_active_filter_organization():
|
||||
|
||||
def test_gallery_cards_keep_badges_without_hover_inspect_label():
|
||||
js = _read("static/js/gallery.js")
|
||||
css = _read("static/style.css")
|
||||
css = app_css()
|
||||
|
||||
assert "gallery-card-overlay" in js
|
||||
assert "gallery-card-inspect" not in js
|
||||
@@ -31,7 +33,7 @@ def test_gallery_cards_keep_badges_without_hover_inspect_label():
|
||||
|
||||
def test_gallery_detail_has_richer_inspector_actions():
|
||||
js = _read("static/js/gallery.js")
|
||||
css = _read("static/style.css")
|
||||
css = app_css()
|
||||
|
||||
assert "gallery-detail-inspector-head" in js
|
||||
assert "gallery-detail-meta-grid" in js
|
||||
@@ -57,7 +59,7 @@ def test_gallery_static_cache_key_bumped():
|
||||
|
||||
def test_gallery_bulk_select_button_keeps_toolbar_alignment():
|
||||
js = _read("static/js/gallery.js")
|
||||
css = _read("static/style.css")
|
||||
css = app_css()
|
||||
|
||||
assert 'id="gallery-select-btn"' in js
|
||||
assert 'id="gallery-select-btn" title="Select for bulk actions" style="position:relative;top:0;"' in js
|
||||
@@ -74,7 +76,7 @@ def test_gallery_bulk_select_button_keeps_toolbar_alignment():
|
||||
def test_gallery_selection_bars_share_style_and_escape_priority():
|
||||
gallery = _read("static/js/gallery.js")
|
||||
ui = _read("static/js/ui.js")
|
||||
css = _read("static/style.css")
|
||||
css = app_css()
|
||||
|
||||
assert gallery.count("memory-bulk-bar gallery-selection-bar hidden") == 3
|
||||
assert "window.__galleryCancelSelection = () =>" in gallery
|
||||
@@ -93,7 +95,7 @@ def test_gallery_selection_bars_share_style_and_escape_priority():
|
||||
|
||||
def test_editor_saved_projects_support_grid_and_list_views():
|
||||
gallery = _read("static/js/gallery.js")
|
||||
css = _read("static/style.css")
|
||||
css = app_css()
|
||||
|
||||
assert 'id="gallery-editor-drafts-view" role="group"' in gallery
|
||||
assert 'data-view="grid"' in gallery
|
||||
@@ -136,7 +138,7 @@ def test_editor_saved_projects_support_grid_and_list_views():
|
||||
|
||||
def test_empty_albums_show_action_tiles_instead_of_no_albums_message():
|
||||
gallery = _read("static/js/gallery.js")
|
||||
css = _read("static/style.css")
|
||||
css = app_css()
|
||||
|
||||
assert "function _albumActionTiles()" in gallery
|
||||
assert 'id="gallery-albums-new"' in gallery
|
||||
@@ -149,7 +151,7 @@ def test_empty_albums_show_action_tiles_instead_of_no_albums_message():
|
||||
|
||||
|
||||
def test_gallery_body_uses_available_window_height_without_grid_crop():
|
||||
css = _read("static/style.css")
|
||||
css = app_css()
|
||||
|
||||
gallery_section = css.index("/* ── Gallery (image library) ── */")
|
||||
modal_start = css.index(".gallery-modal-content {", gallery_section)
|
||||
@@ -161,7 +163,7 @@ def test_gallery_body_uses_available_window_height_without_grid_crop():
|
||||
|
||||
|
||||
def test_gallery_photo_search_is_not_vertically_offset():
|
||||
css = _read("static/style.css")
|
||||
css = app_css()
|
||||
|
||||
search_idx = css.index(".gallery-search-wrap {")
|
||||
assert "top: 0;" in css[search_idx:search_idx + 180]
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
from tests.helpers.stylesheets import app_css
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def test_mobile_notes_checklist_rows_remain_tappable():
|
||||
css = (ROOT / "static" / "style.css").read_text(encoding="utf-8")
|
||||
css = app_css()
|
||||
notes = (ROOT / "static" / "js" / "notes.js").read_text(encoding="utf-8")
|
||||
|
||||
assert "body.notes-mobile-mode .note-card .note-checkbox {" in css
|
||||
@@ -39,7 +41,7 @@ def test_mobile_long_press_enters_select_mode_for_that_note():
|
||||
|
||||
|
||||
def test_mobile_select_mode_has_subtle_jiggle_feedback():
|
||||
css = (ROOT / "static" / "style.css").read_text(encoding="utf-8")
|
||||
css = app_css()
|
||||
|
||||
assert "@keyframes notes-select-jiggle" in css
|
||||
assert "body.notes-mobile-mode .note-card-selectmode" in css
|
||||
@@ -81,7 +83,7 @@ def test_calendar_email_attachments_have_a_calendar_import_action():
|
||||
|
||||
def test_mobile_bulk_select_long_press_is_shared_across_card_types():
|
||||
helper = (ROOT / "static" / "js" / "mobileBulkSelect.js").read_text(encoding="utf-8")
|
||||
css = (ROOT / "static" / "style.css").read_text(encoding="utf-8")
|
||||
css = app_css()
|
||||
|
||||
assert ".memory-item[data-memory-id]" in helper
|
||||
assert ".skill-card[data-skill-name]" in helper
|
||||
|
||||
@@ -3,13 +3,15 @@
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
from tests.helpers.stylesheets import app_css
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def test_history_list_participates_in_shared_library_expansion_layout():
|
||||
panel = (ROOT / "static/js/research/panel.js").read_text(encoding="utf-8")
|
||||
css = (ROOT / "static/style.css").read_text(encoding="utf-8")
|
||||
css = app_css()
|
||||
|
||||
assert 'id="research-past-list" class="doclib-grid memory-list research-jobs-list"' in panel
|
||||
card = css[css.index("#research-pane .research-history-card {"):]
|
||||
@@ -38,7 +40,7 @@ def test_history_list_participates_in_shared_library_expansion_layout():
|
||||
|
||||
|
||||
def test_expanded_history_report_owns_and_scrolls_the_available_height():
|
||||
css = (ROOT / "static/style.css").read_text(encoding="utf-8")
|
||||
css = app_css()
|
||||
|
||||
expanded = (
|
||||
"#research-pane #research-past-list "
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from pathlib import Path
|
||||
|
||||
from tests.helpers.stylesheets import app_css
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
@@ -19,7 +21,7 @@ def test_active_research_visualizer_has_live_hierarchy_and_balanced_layout():
|
||||
|
||||
|
||||
def test_active_research_visualizer_respects_reduced_motion():
|
||||
css = (ROOT / "static/style.css").read_text(encoding="utf-8")
|
||||
css = app_css()
|
||||
|
||||
assert "@media (prefers-reduced-motion: reduce)" in css
|
||||
assert ".research-synapse .rs-live-dot" in css
|
||||
|
||||
@@ -1,11 +1,23 @@
|
||||
from pathlib import Path
|
||||
|
||||
from tests.helpers.stylesheets import app_css
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def _occurrences(text, needle):
|
||||
"""Every index of needle, so an assertion does not depend on which copy of
|
||||
a selector the cascade happens to put first."""
|
||||
out, i = [], text.find(needle)
|
||||
while i != -1:
|
||||
out.append(i)
|
||||
i = text.find(needle, i + 1)
|
||||
return out
|
||||
|
||||
|
||||
def test_bulk_select_triggers_share_dot_and_x_icons():
|
||||
css = (ROOT / "static" / "style.css").read_text()
|
||||
css = app_css()
|
||||
|
||||
for selector in (
|
||||
"#memory-select-btn",
|
||||
@@ -36,14 +48,14 @@ def test_library_titles_have_highlighted_respective_icons():
|
||||
source = (ROOT / "static" / "js" / "documentLibrary.js").read_text()
|
||||
|
||||
assert source.count('class="doclib-section-title-icon"') == 4
|
||||
assert "color: var(--accent, var(--red));" in (ROOT / "static" / "style.css").read_text()
|
||||
assert "color: var(--accent, var(--red));" in app_css()
|
||||
for title in ("Chats", "Archive", "Research", "Documents"):
|
||||
assert f">{title} <span" in source
|
||||
|
||||
|
||||
def test_library_select_follows_tidy():
|
||||
source = (ROOT / "static" / "js" / "documentLibrary.js").read_text()
|
||||
css = (ROOT / "static" / "style.css").read_text()
|
||||
css = app_css()
|
||||
|
||||
assert source.index('id="doclib-chats-tidy-btn"') < source.index('id="doclib-chats-select-btn"')
|
||||
assert source.index('id="doclib-research-tidy-btn"') < source.index('id="doclib-research-select-btn"')
|
||||
@@ -67,7 +79,7 @@ def test_library_select_follows_tidy():
|
||||
|
||||
def test_library_filter_chips_match_skills_count_markup():
|
||||
source = (ROOT / "static" / "js" / "documentLibrary.js").read_text()
|
||||
css = (ROOT / "static" / "style.css").read_text()
|
||||
css = app_css()
|
||||
|
||||
assert "function _setLibraryCountChipContent" in source
|
||||
assert "chip.replaceChildren(text, value)" in source
|
||||
@@ -89,7 +101,7 @@ def test_launch_cookbook_filter_chips_match_skills():
|
||||
|
||||
|
||||
def test_launch_search_select_and_tags_move_up_together():
|
||||
css = (ROOT / "static" / "style.css").read_text()
|
||||
css = app_css()
|
||||
|
||||
rule = css[css.index("#serve-search,"):css.index("#serve-search,") + 140]
|
||||
assert "#hwfit-cache-select," in rule
|
||||
@@ -109,11 +121,16 @@ def test_launch_search_select_and_tags_move_up_together():
|
||||
|
||||
|
||||
def test_launch_server_selector_and_model_search_move_up_together():
|
||||
css = (ROOT / "static" / "style.css").read_text()
|
||||
css = app_css()
|
||||
|
||||
rule = css[css.index("#hwfit-server-select,"):css.index("#hwfit-server-select,") + 130]
|
||||
assert "#hwfit-search" in rule
|
||||
assert "top: -2px !important;" in rule
|
||||
# The stylesheets are split across several files, so this selector group
|
||||
# is not guaranteed to be the first occurrence of its opening selector.
|
||||
# Check every occurrence instead of a fixed offset into the cascade.
|
||||
rules = [css[i:i + 130] for i in _occurrences(css, "#hwfit-server-select,")]
|
||||
assert any(
|
||||
"#hwfit-search" in rule and "top: -2px !important;" in rule
|
||||
for rule in rules
|
||||
), "no #hwfit-server-select rule groups #hwfit-search with top: -2px !important"
|
||||
|
||||
|
||||
def test_launch_heading_uses_accented_flame_icon():
|
||||
@@ -133,7 +150,7 @@ def test_launch_heading_reports_cached_model_count():
|
||||
|
||||
|
||||
def test_launch_search_matches_document_library_search_treatment():
|
||||
css = (ROOT / "static" / "style.css").read_text()
|
||||
css = app_css()
|
||||
|
||||
rule = css[css.index("#hwfit-search {", css.index("#hwfit-server-select,")):][:700]
|
||||
assert "height: 30px;" in rule
|
||||
@@ -151,7 +168,7 @@ def test_launch_search_matches_document_library_search_treatment():
|
||||
|
||||
def test_memory_filter_chips_match_skills_and_are_not_cropped():
|
||||
source = (ROOT / "static" / "js" / "memory.js").read_text()
|
||||
css = (ROOT / "static" / "style.css").read_text()
|
||||
css = app_css()
|
||||
|
||||
assert "skills-summary-chip memory-filter-chip" in source
|
||||
assert "btn.replaceChildren(label, count)" in source
|
||||
|
||||
@@ -2,11 +2,13 @@ import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from tests.helpers.stylesheets import app_css
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
SKILLS_JS = (ROOT / "static/js/skills.js").read_text(encoding="utf-8")
|
||||
METRICS_JS = (ROOT / "static/js/skillsMetrics.js").read_text(encoding="utf-8")
|
||||
STYLE = (ROOT / "static/style.css").read_text(encoding="utf-8")
|
||||
STYLE = app_css()
|
||||
INDEX = (ROOT / "static/index.html").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user