merge: sync lab after stylesheet split

This commit is contained in:
Alexandre Teixeira
2026-10-01 01:08:15 +01:00
45 changed files with 48107 additions and 47640 deletions
+3 -3
View File
@@ -1,8 +1,8 @@
#!/usr/bin/env python3
"""Computed-style snapshot harness for ``static/style.css``.
"""Computed-style snapshot harness for the shipped app CSS cascade.
``static/style.css`` is a single 51k-line stylesheet whose rendered result
depends on source order: hundreds of selectors are declared more than once and
The app CSS is an ordered multi-file cascade whose rendered result depends
on source order: hundreds of selectors are declared more than once and
``!important`` is used throughout. Any restructuring - extracting a block into
its own file, reordering ``<link>`` tags, moving an ``@media`` rule - can
silently change which declaration wins, and nothing else in the suite would
+18 -2
View File
@@ -1,10 +1,26 @@
/** Card layout and reconciliation against the served assets; no user mutations. */
import { chromium } from 'playwright';
const ORIGIN = 'http://127.0.0.1:7011';
/** The `<link rel="stylesheet">` tags the app shell ships, in shell order. */
async function shellStylesheets() {
const response = await fetch(`${ORIGIN}/static/index.html`);
if (!response.ok) throw new Error(`static/index.html returned ${response.status}`);
const links = (await response.text()).match(/<link\b[^>]*rel=["']stylesheet["'][^>]*>/gi) || [];
if (!links.length) throw new Error('no stylesheet links found in static/index.html');
return links.join('');
}
const browser = await chromium.launch({ headless: true });
try {
const page = await browser.newPage({ viewport: { width: 390, height: 844 } });
await page.goto('http://127.0.0.1:7011/static/test-fixtures/browser-catalog.html');
await page.setContent('<link rel="stylesheet" href="/static/style.css"><main style="padding:16px"><div id="chat-history"><p>Existing conversation</p></div></main>');
await page.goto(`${ORIGIN}/static/test-fixtures/browser-catalog.html`);
// Read the shell's stylesheets out of index.html rather than naming one
// here. style.css is now a set of ordered fragments, and a hardcoded link
// to a file that has moved does not fail - it renders unstyled and the
// layout checks below pass against nothing.
await page.setContent(`${await shellStylesheets()}<main style="padding:16px"><div id="chat-history"><p>Existing conversation</p></div></main>`);
const checks = await page.evaluate(async () => {
const { renderResearchCards } = await import('/static/js/backgroundToolJobs.js');
const box = document.querySelector('#chat-history');
+1 -1
View File
@@ -37,7 +37,7 @@ import { UI_VIS_DEFAULT_OFF, resolveVisibility } from './js/ui_visibility.js?v=2
import tasksModule from './js/tasks.js?v=20260914taskmodel1';
import calendarModule from './js/calendar.js?v=20260914emailsource11';
import notesModule from './js/notes.js?v=20260911notesselectioncancel1';
import adminModule from './js/admin.js?v=20260921chatgptusage2';
import adminModule from './js/admin.js?v=20260929csssplit';
import settingsModule from './js/settings.js?v=20260912writingstyle3';
// Eagerly bind unified minimize/restore behavior across all tool modals.
import './js/modalManager.js';
+183
View File
@@ -0,0 +1,183 @@
/* 00-tokens - part of the former static/style.css.
*
* The stylesheet is split by area, but the split is mechanical: rules were
* moved verbatim and NOT regrouped. The numeric prefix is the load order, and
* that order is what preserves the cascade. A rule sits in the file its
* original position fell into, so an area's rules are not all in its file and
* a file is not only that area's rules.
*
* Do not reorder the <link> tags in static/index.html, and do not move a rule
* between files: with 1,151 redeclarations and 1,886 !important in this
* cascade, either can change which declaration wins.
*
* static/index.html and the PRECACHE list in static/sw.js must agree on this
* order. tests/test_static_stylesheet_manifest.py and
* tests/test_css_computed_style_snapshot.py fail if that drifts.
*/
/* ============================================ */
/* Odysseus UI — Consolidated Stylesheet */
/* ============================================ */
/* ── Variables ──
*
* Theme-public variables (override via theme.js or custom themes):
* Core: --bg, --fg, --panel, --border, --red
* Syntax: --hl-keyword, --hl-string, --hl-comment, --hl-function,
* --hl-number, --hl-builtin, --hl-variable, --hl-params,
* --hl-bg, --hl-fg
* Accents: --accent-primary, --accent-error (set by theme.js)
* Semantic: --color-error, --color-success, --color-warning, --color-danger,
* --color-accent, --color-muted, --color-muted-alt
*/
:root {
/* Core palette */
--bg: #282c34;
--fg: #9cdef2;
--panel: #111;
--border: #355a66;
--red: #e06c75;
/* Were `var(--green)` / `var(--warn)` — self-referential, so they
resolved to invalid and every site fell back to its own literal
(or, for sites with no fallback, painted as transparent/inherit).
Anchor them to real hex so the token layer actually works. */
--green: #50fa7b;
--warn: #f0ad4e;
/* Syntax highlighting */
--hl-bg: #1e2228;
--hl-fg: #9cdef2;
--hl-keyword: #c678dd;
--hl-string: #e5c07b;
--hl-comment: #828997;
--hl-function: #61afef;
--hl-number: #d19a66;
--hl-builtin: #56b6c2;
--hl-variable: #abb2bf;
--hl-params: #a8c0d4;
/* Semantic colors */
--color-error: #ff4444;
--color-error-light: #ff6666;
--color-success: #4caf50;
--color-warning: #f0ad4e;
--color-danger: #c0392b;
--color-recording: #ff3b30;
--color-recording-hover: #d63031;
--color-muted: #888;
--color-muted-alt: #6b7280;
--color-accent: #00aaff;
--color-agent-active: #00ff00;
--color-brand-blue: #3b82f6;
--color-blind-orange: #ff9800;
--color-save-green: var(--color-success);
--color-link-hover: #66c7ff;
--color-subheader: #6b8a94;
--select-bg: var(--bg);
--select-fg: var(--fg);
--select-option-bg: color-mix(in srgb, var(--panel) 74%, var(--bg));
--select-option-fg: var(--fg);
--select-option-active-bg: color-mix(in srgb, var(--accent, var(--red)) 24%, var(--panel));
/* Warm accent — used by the Goals/Today UI in Notes. Lives as a token so
themes can override without touching the goal CSS. */
--accent-warm: #d19a66;
}
:root.light {
--bg: #f5f5f5;
--fg: #2b2b2b;
--panel: #fff;
--border: #bbb;
--hl-bg: #f9f9f9;
--hl-fg: #2b2b2b;
--hl-keyword: #7928a1;
--hl-string: #986801;
--hl-comment: #6a737d;
--hl-function: #005cc5;
--hl-number: #986801;
--hl-builtin: #0070a0;
--hl-variable: #383a42;
--hl-params: #4a4f5c;
--select-bg: #eaeaea;
--select-fg: var(--fg);
--select-option-bg: var(--panel);
--select-option-fg: var(--fg);
--select-option-active-bg: color-mix(in srgb, var(--red) 16%, var(--panel));
}
/* ── Reset & Base ── */
* { box-sizing: border-box; }
html, body { overflow-x: hidden; height: 100%; margin: 0; overscroll-behavior: none; }
body {
background-color: var(--bg);
color: var(--fg);
/* Keep the base padding transition for older layout paths that still adjust
the body directly. Edge docks reserve workspace room on the flex panes
below so left + right docks can coexist without skewing the whole body. */
transition: padding-left 160ms cubic-bezier(0.22, 0.61, 0.36, 1),
padding-right 160ms cubic-bezier(0.22, 0.61, 0.36, 1);
font-family: var(--font-family, 'Fira Code', monospace);
display: flex;
height: 100%;
height: 100dvh; /* dynamic viewport height — adapts when mobile keyboard opens */
overflow: hidden;
}
/* Self-hosted Fira Code font */
@font-face { font-family: 'Fira Code'; font-weight: 300; font-style: normal; font-display: swap; src: url('/static/fonts/FiraCode-Light.woff2') format('woff2'); }
@font-face { font-family: 'Fira Code'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/FiraCode-Regular.woff2') format('woff2'); }
@font-face { font-family: 'Fira Code'; font-weight: 600; font-style: normal; font-display: swap; src: url('/static/fonts/FiraCode-SemiBold.woff2') format('woff2'); }
/* GohuFont is the default UI face across themes; keep the shipped font local
so the default is available before custom-font discovery completes. */
@font-face { font-family: 'GohuFont'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/custom/GohuFont.ttf') format('truetype'); }
/* Self-hosted OpenDyslexic — dyslexia-friendly accessibility font option (SIL OFL 1.1) */
@font-face { font-family: 'OpenDyslexic'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/OpenDyslexic-Regular.woff2') format('woff2'); }
@font-face { font-family: 'OpenDyslexic'; font-weight: 700; font-style: normal; font-display: swap; src: url('/static/fonts/OpenDyslexic-Bold.woff2') format('woff2'); }
/* Code block baseline */
pre, code, .hljs {
font-size: 11px;
line-height: 1.5;
}
/* Scrollbar styling */
@supports selector(::-webkit-scrollbar) {
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--panel);
}
::-webkit-scrollbar-thumb {
background-color: var(--red);
border-radius: 4px;
border: 2px solid var(--panel);
}
::-webkit-scrollbar-thumb:hover {
background-color: color-mix(in srgb, var(--red) 80%, white);
}
}
html {
scrollbar-color: var(--red) var(--panel);
scrollbar-width: thin;
}
/* Utility */
.red-text { color: var(--red); }
/* ── Density Overrides ── */
:root.density-compact { font-size: 13px; }
:root.density-compact .msg { padding: 6px 10px; margin-bottom: 4px; }
:root.density-compact .list-item { padding: 4px 8px; }
:root.density-compact .sidebar .section { padding: 0; }
:root.density-spacious { font-size: 16px; }
:root.density-spacious .msg { padding: 14px 18px; margin-bottom: 12px; }
:root.density-spacious .list-item { padding: 8px 12px; }
:root.density-spacious .sidebar .section { padding: 0; }
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+839
View File
@@ -0,0 +1,839 @@
/* 12-gallery - part of the former static/style.css.
*
* The stylesheet is split by area, but the split is mechanical: rules were
* moved verbatim and NOT regrouped. The numeric prefix is the load order, and
* that order is what preserves the cascade. A rule sits in the file its
* original position fell into, so an area's rules are not all in its file and
* a file is not only that area's rules.
*
* Do not reorder the <link> tags in static/index.html, and do not move a rule
* between files: with 1,151 redeclarations and 1,886 !important in this
* cascade, either can change which declaration wins.
*
* static/index.html and the PRECACHE list in static/sw.js must agree on this
* order. tests/test_static_stylesheet_manifest.py and
* tests/test_css_computed_style_snapshot.py fail if that drifts.
*/
.research-synapse.rs-phase-reading .rs-phase-chip,
.research-synapse.rs-phase-analyzing .rs-phase-chip { color: var(--gold, #d4a72c); }
.research-synapse.rs-phase-writing .rs-phase-chip { color: var(--color-success, #4caf50); }
@media (prefers-reduced-motion: reduce) {
.research-synapse .rs-live-dot,
.research-synapse .rs-pulse,
.research-synapse .rs-node-new,
.research-synapse .rs-node-expanded,
.research-synapse .rs-edge-firing { animation: none !important; }
}
/* ── Raw findings items (inside sources-style box) ── */
.finding-item {
display: flex;
flex-direction: column;
gap: 4px;
}
.finding-item .source-link {
border-radius: 6px 6px 0 0;
}
.finding-summary {
padding: 6px 8px 8px;
font-size: 0.82em;
line-height: 1.5;
color: var(--fg-dim, var(--fg));
opacity: 0.85;
border-bottom: 1px solid color-mix(in srgb, var(--fg) 6%, transparent);
margin-bottom: 4px;
white-space: pre-wrap;
word-break: break-word;
}
.finding-item:last-child .finding-summary {
border-bottom: none;
margin-bottom: 0;
}
/* ═══════════════════════════════════════════════════════════
Gallery Editor
═══════════════════════════════════════════════════════════ */
/* Tab bar */
.gallery-tabs {
display: flex;
gap: 0;
border-bottom: 1px solid var(--border);
padding: 0 16px;
background: var(--panel);
}
.gallery-tab {
padding: 8px 18px;
background: none;
border: none;
border-bottom: 2px solid transparent;
color: var(--fg);
opacity: 0.6;
cursor: pointer;
font-size: 13px;
font-weight: 500;
transition: opacity 0.15s, border-color 0.15s;
}
.gallery-tab:hover { opacity: 0.85; }
.gallery-tab.active {
opacity: 1;
border-bottom-color: var(--red);
}
/* Narrow modal tab strips should stay on one row. Resized docked windows can
be much narrower than the viewport, so this cannot live only in mobile media
queries. */
.cookbook-tabs,
.memory-tabs,
.admin-tabs,
.lib-tabs,
.gallery-tabs,
.preset-tabs {
flex-wrap: nowrap !important;
overflow-x: auto !important;
overflow-y: hidden;
-webkit-overflow-scrolling: touch;
overscroll-behavior-x: contain;
scrollbar-width: none;
}
.cookbook-tabs::-webkit-scrollbar,
.memory-tabs::-webkit-scrollbar,
.admin-tabs::-webkit-scrollbar,
.lib-tabs::-webkit-scrollbar,
.gallery-tabs::-webkit-scrollbar,
.preset-tabs::-webkit-scrollbar {
display: none;
}
.cookbook-tabs > *,
.memory-tabs > *,
.admin-tabs > *,
.lib-tabs > *,
.gallery-tabs > *,
.preset-tabs > * {
flex: 0 0 auto;
}
.cookbook-tab,
.memory-tab,
.admin-tab,
.lib-tab,
.gallery-tab,
.preset-tab {
display: inline-flex;
align-items: center;
justify-content: center;
white-space: nowrap;
line-height: 1;
}
.gallery-tab {
gap: 6px;
}
@container (max-width: 360px) {
.cookbook-tab:has(svg),
.memory-tab:has(svg),
.admin-tab:has(svg),
.lib-tab:has(svg),
.gallery-tab:has(svg),
.preset-tab:has(svg) {
width: 34px;
min-width: 34px;
padding-left: 0;
padding-right: 0;
font-size: 0;
}
.cookbook-tab:has(svg) svg,
.memory-tab:has(svg) svg,
.admin-tab:has(svg) svg,
.lib-tab:has(svg) svg,
.gallery-tab:has(svg) svg,
.preset-tab:has(svg) svg {
width: 14px;
height: 14px;
margin-right: 0 !important;
vertical-align: middle !important;
}
.memory-tab:has(svg) .memory-count,
.gallery-tab:has(svg) .gallery-tab-label,
.gallery-tab:has(svg) .gallery-tab-close,
.cookbook-tab:has(svg) .cookbook-tab-count,
.preset-tab:has(svg) .preset-count {
display: none !important;
}
}
/* Icon + label layout inside each tab. */
.gallery-tab {
display: inline-flex;
align-items: center;
gap: 6px;
}
.gallery-tab-icon {
display: inline-flex;
align-items: center;
opacity: 0.85;
}
.gallery-tab.active .gallery-tab-icon { opacity: 1; }
/* Close × on the Edit tab — appears on hover. */
.gallery-tab-close {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
margin-left: 2px;
border-radius: 4px;
opacity: 0;
transition: opacity 0.12s, background 0.12s;
/* Use a slightly larger glyph at a larger line-height so the × sits
visually centered — the U+00D7 character has uneven em-box metrics. */
font-size: 16px;
line-height: 16px;
font-weight: 500;
cursor: pointer;
padding-bottom: 2px;
box-sizing: border-box;
}
.gallery-tab:hover .gallery-tab-close,
.gallery-tab.active .gallery-tab-close { opacity: 0.65; }
/* Hide the close × entirely on the Edit tab when there's no edit open. */
.gallery-tab[data-tab="editor"]:not(.has-edit) .gallery-tab-close { display: none !important; }
.gallery-tab-close:hover { opacity: 1 !important; background: color-mix(in srgb, var(--red) 25%, transparent); color: var(--red); }
/* Inline rename input shown when the Edit tab is double-clicked. */
.gallery-tab-rename-input {
background: var(--bg);
border: 1px solid var(--red);
border-radius: 3px;
color: var(--fg);
font: inherit;
font-size: 13px;
padding: 1px 4px;
width: 140px;
outline: none;
}
/* Albums tab — grid of album cards with cover thumbnails. */
.gallery-albums-container {
padding: 12px 4px;
max-height: calc(92vh - 120px);
overflow-y: auto;
border: 2px dashed transparent;
border-radius: 8px;
transition: border-color 0.15s, background 0.15s;
}
.gallery-settings-container {
padding: 8px 4px;
max-height: calc(92vh - 120px);
overflow-y: auto;
}
.gallery-albums-container.gallery-dragover {
border-color: var(--red);
background: color-mix(in srgb, var(--red) 5%, transparent);
}
.gallery-albums-empty {
display: flex; flex-direction: column; align-items: center; gap: 12px;
padding: 48px 16px;
opacity: 0.7;
font-size: 13px;
}
.gallery-albums-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 12px;
}
.gallery-album-card {
position: relative;
display: flex;
flex-direction: column;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
cursor: pointer;
transition: transform 0.12s, border-color 0.12s, box-shadow 0.12s;
}
.gallery-album-card:hover {
transform: translateY(-2px);
border-color: var(--red);
box-shadow: 0 4px 14px color-mix(in srgb, var(--fg) 12%, transparent);
}
.gallery-album-menu-btn {
position: absolute;
top: 6px; right: 6px;
width: 24px; height: 24px;
display: flex; align-items: center; justify-content: center;
background: color-mix(in srgb, var(--bg) 70%, transparent);
color: var(--fg);
border: 1px solid color-mix(in srgb, var(--border) 60%, transparent);
border-radius: 6px;
cursor: pointer;
opacity: 0;
transition: opacity 0.12s, background 0.12s;
z-index: 2;
backdrop-filter: blur(4px);
/* Plain "…" text — the previous SVG was rendering as a flat black
block on some themes when its currentColor didn't contrast. */
font-size: 16px;
line-height: 1;
font-weight: 600;
padding: 0 0 4px;
}
.gallery-album-card:hover .gallery-album-menu-btn,
.gallery-album-menu-btn:focus-visible { opacity: 1; }
.gallery-album-menu-btn:hover { background: var(--bg); }
.gallery-album-menu-pop {
position: absolute;
top: 34px; right: 6px;
min-width: 140px;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 8px;
box-shadow: 0 4px 14px color-mix(in srgb, var(--fg) 18%, transparent);
display: flex; flex-direction: column;
padding: 4px;
z-index: 3;
}
.gallery-album-menu-pop[hidden] { display: none; }
.gallery-album-cover {
aspect-ratio: 4 / 3;
background: color-mix(in srgb, var(--fg) 5%, var(--bg));
display: flex; align-items: center; justify-content: center;
overflow: hidden;
}
.gallery-album-placeholder {
opacity: 0.4;
font-size: 40px;
display: flex; align-items: center; justify-content: center;
width: 100%; height: 100%;
}
.gallery-album-info {
padding: 8px 10px;
display: flex; flex-direction: column; gap: 2px;
}
.gallery-album-name {
font-size: 13px;
font-weight: 500;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.gallery-album-count {
font-size: 11px;
opacity: 0.55;
}
.gallery-album-card-add {
border-style: dashed;
opacity: 0.7;
}
.gallery-album-card-add:hover { opacity: 1; }
.gallery-album-action-tile {
background: color-mix(in srgb, var(--fg) 3%, var(--bg));
}
/* Edit-tab empty state — shown before any image is loaded. */
.gallery-editor-landing {
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 10px;
padding: 28px 16px;
flex: 1;
text-align: center;
color: var(--fg);
}
.gallery-editor-landing h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
}
.gallery-editor-heading {
display: inline-flex;
align-items: center;
gap: 7px;
}
.gallery-editor-heading > svg { opacity: 0.6; flex: 0 0 auto; }
/* "vision model" link in the AI-tagging description → opens Settings. */
.ge-vision-link {
color: var(--accent, var(--red));
text-decoration: underline;
text-underline-offset: 2px;
cursor: pointer;
}
.ge-vision-link:hover { opacity: 0.8; }
.ge-alpha-tag {
font-size: 9px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
vertical-align: middle;
padding: 1px 5px;
border-radius: 4px;
color: var(--accent, var(--red));
background: color-mix(in srgb, var(--accent, var(--red)) 15%, transparent);
position: relative;
top: -1px;
}
.gallery-editor-landing-actions {
display: flex; gap: 10px;
margin-top: 8px;
}
/* Bigger primary action buttons specifically inside the editor landing —
the gallery-wide .gallery-select-btn is a compact toolbar style which is
too small for the empty-state hero. */
.gallery-editor-landing-actions .gallery-select-btn {
padding: 7px 22px 17px;
font-size: 13px;
opacity: 0.85;
}
/* Visible template picker: every canvas ratio is inspectable without opening
a native menu, and its SVG outline previews the resulting canvas shape. */
.gallery-editor-template-picker {
width: min(760px, 96%);
margin-top: 10px;
}
.gallery-editor-template-picker h4 {
margin: 0 0 7px;
font-size: 11px;
opacity: 0.55;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.gallery-editor-template-options {
display: grid;
grid-template-columns: repeat(4, minmax(105px, 1fr));
gap: 6px;
}
.gallery-editor-template-option {
height: 72px;
min-width: 0;
padding: 6px 7px;
display: grid;
grid-template-rows: 28px auto auto;
justify-items: center;
align-items: center;
background: var(--panel);
color: var(--fg);
border: 1px solid var(--border);
border-radius: 5px;
font: inherit;
font-size: 11px;
cursor: pointer;
overflow: hidden;
}
.gallery-editor-template-option svg { color: var(--accent, var(--red)); }
.gallery-editor-template-option:hover,
.gallery-editor-template-option:focus-visible {
outline: none;
border-color: var(--accent, var(--red));
color: var(--accent, var(--red));
background: color-mix(in srgb, var(--accent, var(--red)) 7%, var(--panel));
}
.gallery-editor-template-option span {
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 600;
}
.gallery-editor-template-option small {
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 9px;
opacity: 0.52;
}
@media (max-width: 620px) {
.gallery-editor-landing {
justify-content: flex-start;
gap: 7px;
padding: 18px 12px;
overflow-y: auto;
}
.gallery-editor-landing-actions {
width: 100%;
gap: 6px;
justify-content: center;
}
.gallery-editor-landing-actions .gallery-select-btn {
flex: 1 1 0;
min-width: 0;
max-width: 180px;
padding: 6px 8px 10px;
font-size: 11px;
}
.gallery-editor-template-picker {
width: 100%;
margin-top: 4px;
}
.gallery-editor-template-options {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
/* The common square, widescreen, portrait, and Instagram presets are
enough to start on a phone. Keep the print and 4K presets on desktop. */
.gallery-editor-template-option:nth-child(n + 5) {
display: none;
}
}
/* Saved-drafts grid on the editor landing. Surfaces every persisted
in-progress project so the user can resume without re-opening the
original photo. Each card: thumbnail + name + last-saved hint + ×. */
.gallery-editor-drafts {
width: min(720px, 92%);
margin: 28px auto 0; /* centered */
padding-top: 18px;
border-top: 1px solid var(--border);
display: flex;
flex-direction: column;
gap: 10px;
}
.gallery-editor-drafts {
position: relative;
}
.gallery-editor-drafts-loading {
position: absolute;
inset: 30px 0 0 0;
z-index: 4;
display: flex;
align-items: center;
justify-content: center;
background: var(--panel);
border-radius: 6px;
min-height: 80px;
}
.gallery-editor-drafts-header {
display: flex;
align-items: center;
justify-content: center; /* center the title / search / select */
gap: 8px;
margin-bottom: 8px;
}
.gallery-editor-drafts-title {
margin: 0;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.06em;
opacity: 0.55;
font-weight: 600;
text-align: left;
flex-shrink: 0;
}
.gallery-editor-drafts-search {
flex: 1 1 auto;
min-width: 0;
max-width: none;
height: 26px; /* same thickness as the Select button */
box-sizing: border-box;
padding: 0 8px;
font-size: 12px;
background: var(--bg);
color: var(--fg);
border: 1px solid var(--border);
border-radius: 4px;
}
.gallery-editor-drafts-search:focus { outline: none; border-color: var(--red); }
/* The shared .gallery-select-btn has a -4px top margin + tall asymmetric padding
meant for the main gallery toolbar; in this header it makes the button a
different height / offset from the search box. Normalize so it lines up. */
.gallery-editor-drafts-header .gallery-select-btn {
margin-top: 0 !important;
height: 26px; /* match the search bar */
box-sizing: border-box;
padding: 0 10px;
font-size: 12px;
line-height: 1;
flex-shrink: 0;
}
.cookbook-official-filter {
display: inline-flex;
align-items: center;
gap: 4px;
flex: 0 1 auto;
min-width: 0;
max-width: 100%;
color: var(--fg);
font-size: 10px;
opacity: 0.72;
white-space: nowrap;
position: relative;
top: 1px;
}
.cookbook-official-filter > span:first-child {
position: relative;
top: 1px;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
font-size: 9px;
}
.cookbook-official-filter .toggle {
width: 26px;
height: 14px;
}
.cookbook-official-filter .toggle .slider:before {
width: 10px;
height: 10px;
left: 2px;
bottom: 2px;
}
.cookbook-official-filter .toggle input:checked + .slider:before {
transform: translateX(12px);
}
#gallery-editor-drafts-select { margin-left: auto; flex-shrink: 0; }
#gallery-editor-drafts-select.active {
background: color-mix(in srgb, var(--accent, var(--red)) 18%, transparent);
border-color: var(--accent, var(--red));
color: var(--accent, var(--red));
}
/* Graceful exit when a project card is deleted — fade + shrink before re-render. */
.gallery-editor-draft-card.gallery-draft-removing {
opacity: 0;
transform: scale(0.92);
transition: opacity 0.24s ease, transform 0.24s ease;
pointer-events: none;
}
/* Draft select dot uses the standard small .gallery-select-dot look (same as the
photo grid) — no oversized white-ring checkbox. */
.gallery-editor-drafts-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 10px;
}
.gallery-drafts-just-opened > .gallery-editor-draft-card {
animation: section-domino-in 0.36s cubic-bezier(0.22, 1.61, 0.36, 1) backwards;
}
.gallery-drafts-just-opened > :nth-child(1) { animation-delay: 0.02s; }
.gallery-drafts-just-opened > :nth-child(2) { animation-delay: 0.04s; }
.gallery-drafts-just-opened > :nth-child(3) { animation-delay: 0.06s; }
.gallery-drafts-just-opened > :nth-child(4) { animation-delay: 0.08s; }
.gallery-drafts-just-opened > :nth-child(5) { animation-delay: 0.10s; }
.gallery-drafts-just-opened > :nth-child(6) { animation-delay: 0.12s; }
.gallery-drafts-just-opened > :nth-child(7) { animation-delay: 0.14s; }
.gallery-drafts-just-opened > :nth-child(8) { animation-delay: 0.16s; }
.gallery-drafts-just-opened > :nth-child(9) { animation-delay: 0.18s; }
.gallery-drafts-just-opened > :nth-child(10) { animation-delay: 0.20s; }
.gallery-drafts-just-opened > :nth-child(11) { animation-delay: 0.22s; }
.gallery-drafts-just-opened > :nth-child(12) { animation-delay: 0.24s; }
.gallery-drafts-just-opened > :nth-child(13) { animation-delay: 0.26s; }
.gallery-drafts-just-opened > :nth-child(14) { animation-delay: 0.28s; }
.gallery-drafts-just-opened > :nth-child(15) { animation-delay: 0.30s; }
.gallery-drafts-just-opened > :nth-child(16) { animation-delay: 0.32s; }
.gallery-drafts-just-opened > :nth-child(17) { animation-delay: 0.34s; }
.gallery-drafts-just-opened > :nth-child(18) { animation-delay: 0.36s; }
.gallery-drafts-just-opened > :nth-child(19) { animation-delay: 0.38s; }
.gallery-drafts-just-opened > :nth-child(20) { animation-delay: 0.40s; }
.gallery-drafts-just-opened > :nth-child(n+21) { animation-delay: 0.42s; }
.gallery-drafts-view-toggle {
display: inline-flex;
position: relative;
top: 0;
height: 24px;
flex: 0 0 auto;
overflow: hidden;
border: 1px solid var(--border);
border-radius: 5px;
background: var(--bg);
}
.gallery-drafts-view-toggle button {
width: 25px;
height: 22px;
margin: 0;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
border: 0;
border-right: 1px solid var(--border);
background: transparent;
color: color-mix(in srgb, var(--fg) 55%, transparent);
cursor: pointer;
line-height: 0;
}
.gallery-drafts-view-toggle button:last-child { border-right: 0; }
.gallery-drafts-view-toggle button:hover { color: var(--fg); }
.gallery-drafts-view-toggle button.active {
height: 22px;
color: var(--accent, var(--red));
background: color-mix(in srgb, var(--accent, var(--red)) 12%, transparent);
}
.gallery-editor-drafts-grid.list-view .gallery-editor-draft-thumb {
width: 44px;
height: 44px;
aspect-ratio: auto;
}
.gallery-editor-drafts-grid.list-view .gallery-editor-draft-delete {
position: static;
opacity: 0.55;
background: transparent;
color: var(--fg);
}
.gallery-editor-draft-delete-trash { display: none; }
.gallery-editor-drafts-grid.list-view .gallery-editor-draft-delete-trash {
display: block;
width: 18px;
height: 18px;
position: relative;
top: -2px;
}
.gallery-editor-drafts-grid.list-view .gallery-editor-draft-delete:hover {
opacity: 1;
color: var(--color-error, #f44);
background: color-mix(in srgb, var(--color-error, #f44) 10%, transparent);
}
.gallery-editor-draft-card {
position: relative;
display: flex;
flex-direction: column;
gap: 4px;
padding: 6px;
border: 1px solid var(--border);
border-radius: 8px;
background: color-mix(in srgb, var(--panel) 70%, transparent);
cursor: pointer;
transition: border-color 0.15s, background 0.15s, transform 0.15s;
text-align: left;
}
.gallery-editor-draft-card:hover {
border-color: var(--accent, var(--red));
background: color-mix(in srgb, var(--panel) 90%, transparent);
}
.gallery-editor-draft-thumb {
width: 100%;
aspect-ratio: 1;
object-fit: cover;
border-radius: 4px;
background: var(--bg);
display: block;
}
.gallery-editor-draft-thumb-empty {
background: repeating-linear-gradient(
45deg,
color-mix(in srgb, var(--fg) 4%, transparent),
color-mix(in srgb, var(--fg) 4%, transparent) 6px,
transparent 6px,
transparent 12px
);
}
.gallery-editor-draft-info {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.gallery-editor-draft-name {
font-size: 12px;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.gallery-editor-draft-meta {
font-size: 10px;
opacity: 0.55;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.gallery-editor-draft-delete {
position: absolute;
top: -2px;
right: 8px;
width: 22px;
height: 22px;
border-radius: 50%;
border: none;
background: rgba(0, 0, 0, 0.55);
color: #fff;
font-size: 14px;
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
opacity: 0;
transition: opacity 0.12s, background 0.12s;
}
.gallery-editor-draft-delete {
padding-bottom: 2px;
}
.gallery-editor-draft-delete:hover { background: var(--red); }
/* On very narrow phones, give project search its own row instead of
compressing the title and view controls into an overflowing header. */
@media (max-width: 360px) {
.gallery-editor-drafts-header {
flex-wrap: wrap;
justify-content: flex-start;
gap: 6px;
}
.gallery-editor-drafts-title {
flex: 1 1 auto;
}
.gallery-editor-drafts-search {
order: 10;
flex: 1 1 100%;
width: 100%;
max-width: none;
}
#gallery-editor-drafts-select {
margin-left: 0;
}
}
/* Editor layout */
.gallery-editor {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
height: 100%;
overflow: hidden;
/* Prevent the editor's own scroll/touch from chaining out to the
gallery modal or the page body — critical on mobile so touching
the canvas doesn't slide the whole page. */
overscroll-behavior: contain;
touch-action: pan-y;
}
/* Pin the topbar to the top of the editor so its action buttons remain
visible if the gallery modal body itself ends up scrolling. */
.ge-topbar { position: sticky; top: 0; z-index: 5; }
/* Top bar */
.ge-topbar {
display: flex;
flex-wrap: nowrap;
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: thin;
align-items: center;
justify-content: space-between;
padding: 6px 10px;
background: var(--panel);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
gap: 8px;
}
.ge-topbar-left, .ge-topbar-right {
display: flex;
flex: 0 0 auto;
align-items: center;
gap: 4px;
}
.ge-topbar-right > * {
flex-shrink: 0;
}
.ge-topbar .dropdown[popover] {
position: fixed;
margin: 0;
bottom: auto;
max-height: calc(100vh - 80px);
overflow-y: auto;
}
.ge-draft-status {
flex: 0 1 auto;
min-width: 0;
max-width: 92px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: color-mix(in srgb, var(--fg) 52%, transparent);
font-size: 10px;
line-height: 1;
transition: color 0.15s, opacity 0.15s;
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,22 +1,20 @@
/* Cookbook (including What Fits?), deep research, memory, settings and admin.
*
* 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.
* This is an eagerly loaded tail segment of the app stylesheet cascade. The
* former static/style.css now lives in the numbered 00-17 fragments; these
* three pre-existing panel files follow those fragments without changing
* their relative order. 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
* LOAD ORDER IS PART OF THE CONTRACT. static/index.html and the PRECACHE list
* in static/sw.js must agree:
* 1. static/css/00-tokens.css through static/css/17-research.css,
* in numeric order
* 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.
* Rules in this file retain their existing cascade position. Shared rules
* formerly kept in static/style.css now live in the numbered fragments.
*/
/* Cookbook already shows its own running/served-status dot
+10 -12
View File
@@ -1,22 +1,20 @@
/* 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.
* This is an eagerly loaded tail segment of the app stylesheet cascade. The
* former static/style.css now lives in the numbered 00-17 fragments; these
* three pre-existing panel files follow those fragments without changing
* their relative order. 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
* LOAD ORDER IS PART OF THE CONTRACT. static/index.html and the PRECACHE list
* in static/sw.js must agree:
* 1. static/css/00-tokens.css through static/css/17-research.css,
* in numeric order
* 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.
* Rules in this file retain their existing cascade position. Shared rules
* formerly kept in static/style.css now live in the numbered fragments.
*/
/* Document library search-term highlight — clear, high-contrast. */
+10 -12
View File
@@ -1,22 +1,20 @@
/* Email, calendar, notes and tasks panels.
*
* 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.
* This is an eagerly loaded tail segment of the app stylesheet cascade. The
* former static/style.css now lives in the numbered 00-17 fragments; these
* three pre-existing panel files follow those fragments without changing
* their relative order. 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
* LOAD ORDER IS PART OF THE CONTRACT. static/index.html and the PRECACHE list
* in static/sw.js must agree:
* 1. static/css/00-tokens.css through static/css/17-research.css,
* in numeric order
* 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.
* Rules in this file retain their existing cascade position. Shared rules
* formerly kept in static/style.css now live in the numbered fragments.
*/
@keyframes email-notif-breathe {
+23 -6
View File
@@ -246,15 +246,32 @@
real request is discarded and the font fetched a second time. -->
<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">
<link rel="stylesheet" href="/static/css/00-tokens.css?v=20260929csssplit">
<link rel="stylesheet" href="/static/css/01-agent-chat.css?v=20260929csssplit">
<link rel="stylesheet" href="/static/css/02-compare.css?v=20260929csssplit">
<link rel="stylesheet" href="/static/css/03-agent-chat.css?v=20260929csssplit">
<link rel="stylesheet" href="/static/css/04-memory.css?v=20260929csssplit">
<link rel="stylesheet" href="/static/css/05-documents.css?v=20260929csssplit">
<link rel="stylesheet" href="/static/css/06-admin-settings.css?v=20260929csssplit">
<link rel="stylesheet" href="/static/css/07-documents.css?v=20260929csssplit">
<link rel="stylesheet" href="/static/css/08-skills.css?v=20260929csssplit">
<link rel="stylesheet" href="/static/css/09-gallery.css?v=20260929csssplit">
<link rel="stylesheet" href="/static/css/10-cookbook.css?v=20260929csssplit">
<link rel="stylesheet" href="/static/css/11-tasks.css?v=20260929csssplit">
<link rel="stylesheet" href="/static/css/12-gallery.css?v=20260929csssplit">
<link rel="stylesheet" href="/static/css/13-image-editor.css?v=20260929csssplit">
<link rel="stylesheet" href="/static/css/14-email.css?v=20260929csssplit">
<link rel="stylesheet" href="/static/css/15-notes.css?v=20260929csssplit">
<link rel="stylesheet" href="/static/css/16-calendar.css?v=20260929csssplit">
<link rel="stylesheet" href="/static/css/17-research.css?v=20260929csssplit">
<!-- 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="stylesheet" href="/static/css/documents-gallery-editor.css?v=20260929csssplit">
<link rel="stylesheet" href="/static/css/email-calendar-notes-tasks.css?v=20260929csssplit">
<link rel="stylesheet" href="/static/css/cookbook-research-memory-settings.css?v=20260929csssplit">
<link rel="modulepreload" href="/static/app.js?v=20260929csssplit">
<link rel="modulepreload" href="/static/js/chat.js?v=20260917toolttft1">
<link rel="modulepreload" href="/static/js/ui.js?v=20260916largetoolscroll1">
<link rel="modulepreload" href="/static/js/sessions.js">
@@ -2639,7 +2656,7 @@
<script type="module" src="/static/js/censor.js"></script>
<script type="module" src="/static/js/settings.js?v=20260912writingstyle3"></script>
<script type="module" src="/static/js/assistant.js?v=20260912firefoxjscleanup1"></script>
<script type="module" src="/static/app.js?v=20260921chatgptusage2"></script> <!-- app.js must be LAST -->
<script type="module" src="/static/app.js?v=20260929csssplit"></script> <!-- app.js must be LAST -->
<script type="module" src="/static/js/init.js?v=20260829chatstyle12"></script>
<script type="module" src="/static/js/a11y.js"></script>
<script nonce="{{CSP_NONCE}}">if('serviceWorker' in navigator){navigator.serviceWorker.register('/static/sw.js?v=20260916autoscroll1').catch(()=>{});}</script>
+1 -1
View File
@@ -316,7 +316,7 @@ class SmoothPad {
}
function _modal(innerHtml) {
// Match the app's standard .modal pattern (defined in static/style.css).
// Match the app's standard .modal pattern from the main stylesheet cascade.
const overlay = document.createElement('div');
overlay.className = 'modal sig-modal-overlay';
overlay.style.cssText = 'pointer-events:auto;background:rgba(0,0,0,0.45);z-index:10100;';
+4 -4
View File
@@ -106,7 +106,7 @@
<style>
@font-face { font-family: 'Fira Code'; font-weight: 400; font-style: normal; font-display: swap; src: url('static/fonts/FiraCode-Regular.woff2') format('woff2'); }
@font-face { font-family: 'Fira Code'; font-weight: 600; font-style: normal; font-display: swap; src: url('static/fonts/FiraCode-SemiBold.woff2') format('woff2'); }
/* Mirror the main app's :root defaults (static/style.css ~line 18) so an
/* Mirror the main app's :root defaults from the stylesheet cascade so an
uncustomized theme — or a fresh browser with no `odysseus-theme` in
localStorage — renders the login page in the same palette as the rest
of the app instead of a different greys+offwhite scheme. */
@@ -115,7 +115,7 @@
--red: #e06c75; --green: #50fa7b;
--bg-effect-intensity: 1;
}
/* Background patterns — mirrors static/style.css "Background Patterns"
/* Background patterns — mirrors the main app "Background Patterns" styles
block so the theme's chosen bgPattern renders on login too. The
canvas-based effects (rain, constellations, perlin-flow, petals,
sparkles, embers) are spawned by the deferred theme.js import below;
@@ -157,7 +157,7 @@
font-size: 2rem; font-weight: 600;
letter-spacing: 0.04em;
/* Same gradient as the main app's welcome-screen "Odysseus" title
(static/style.css #welcome-screen .welcome-name) so the brand
(the main cascade's #welcome-screen .welcome-name) so the brand
wordmark is consistent across login + first-launch screens. */
background: linear-gradient(135deg, var(--brand-color, var(--red)), color-mix(in srgb, var(--brand-color, var(--red)) 60%, var(--fg)));
-webkit-background-clip: text;
@@ -175,7 +175,7 @@
/* On touch devices keep inputs at >=16px so iOS Safari doesn't zoom the whole
page when a field is focused (it auto-zooms any focused input under 16px).
This page has its own inline styles, so it doesn't inherit the main app's
equivalent rule in static/style.css; mirror it here. !important also lifts
equivalent rule in the main stylesheet cascade; mirror it here. !important also lifts
the dynamically-inserted 2FA input, which pins font-size:14px inline. */
@media (hover: none) and (pointer: coarse) {
input:not(.remember-check) { font-size: 16px !important; }
-47512
View File
File diff suppressed because it is too large Load Diff
+23 -6
View File
@@ -39,11 +39,28 @@ const KATEX_FONTS = [
// exact URL the browser requests, query string included.
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/css/00-tokens.css?v=20260929csssplit',
'/static/css/01-agent-chat.css?v=20260929csssplit',
'/static/css/02-compare.css?v=20260929csssplit',
'/static/css/03-agent-chat.css?v=20260929csssplit',
'/static/css/04-memory.css?v=20260929csssplit',
'/static/css/05-documents.css?v=20260929csssplit',
'/static/css/06-admin-settings.css?v=20260929csssplit',
'/static/css/07-documents.css?v=20260929csssplit',
'/static/css/08-skills.css?v=20260929csssplit',
'/static/css/09-gallery.css?v=20260929csssplit',
'/static/css/10-cookbook.css?v=20260929csssplit',
'/static/css/11-tasks.css?v=20260929csssplit',
'/static/css/12-gallery.css?v=20260929csssplit',
'/static/css/13-image-editor.css?v=20260929csssplit',
'/static/css/14-email.css?v=20260929csssplit',
'/static/css/15-notes.css?v=20260929csssplit',
'/static/css/16-calendar.css?v=20260929csssplit',
'/static/css/17-research.css?v=20260929csssplit',
'/static/css/documents-gallery-editor.css?v=20260929csssplit',
'/static/css/email-calendar-notes-tasks.css?v=20260929csssplit',
'/static/css/cookbook-research-memory-settings.css?v=20260929csssplit',
'/static/app.js?v=20260929csssplit',
'/static/js/storage.js',
'/static/js/appConfig.js',
'/static/js/ui.js?v=20260916largetoolscroll1',
@@ -78,7 +95,7 @@ const PRECACHE = [
'/static/js/theme.js?v=20260911organsrain1',
'/static/js/censor.js',
'/static/js/settings.js?v=20260912writingstyle3',
'/static/js/admin.js?v=20260921chatgptusage2',
'/static/js/admin.js?v=20260929csssplit',
'/static/js/chatgptSubscriptionUsage.js',
'/static/js/init.js?v=20260829chatstyle12',
'/static/js/slashCommands.js?v=20260921chatgptusage1',
+4 -5
View File
@@ -150,7 +150,7 @@ swallowed.
## CSS computed-style snapshot
`tests/test_css_computed_style_snapshot.py` pins the rendered result of
`static/style.css` - one 51k-line file whose behavior depends on source order -
the shipped ordered stylesheet cascade, whose behavior depends on source order,
by hashing `getComputedStyle` over a fixed element inventory across pages,
viewports, themes and density modes. Any PR that moves CSS has to produce an
identical digest or explain why it did not.
@@ -188,14 +188,13 @@ 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
- App styles live across an ordered cascade; reading one fragment 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`.
driven through Playwright, so it gets the whole shipped cascade.
- 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.
+2 -2
View File
@@ -1,6 +1,6 @@
# Computed-style snapshot harness
`static/style.css` is 51,425 lines in one file. Hundreds of selectors are
The app CSS is an ordered multi-file cascade. Hundreds of selectors are
declared more than once and `!important` appears throughout, so the rendered
result is a function of **source order**. Extracting a block into its own file,
reordering `<link>` tags, or moving an `@media` rule can silently change which
@@ -29,7 +29,7 @@ so the measurements stay independent.
The **bench** page measures one synthesised element per selector, built from
the selector itself. Its selector list is evidence-driven: every selector
declared **more than once** in `style.css` that can be expressed as a static
declared **more than once** in the app cascade that can be expressed as a static
compound chain (551 of them), plus a curated set covering chat, documents,
email, notes, calendar, settings, cookbook and gallery. Redeclared selectors
are the ones a reorder can actually flip, so they are the ones worth benching.
+21 -1
View File
@@ -9,7 +9,27 @@
for opening the page by hand; it is deliberately unversioned so the
cache-bust string has one home (tests/test_static_stylesheet_manifest.py
pins the shipped ones). -->
<link rel="stylesheet" href="/static/style.css">
<link rel="stylesheet" href="/static/css/00-tokens.css">
<link rel="stylesheet" href="/static/css/01-agent-chat.css">
<link rel="stylesheet" href="/static/css/02-compare.css">
<link rel="stylesheet" href="/static/css/03-agent-chat.css">
<link rel="stylesheet" href="/static/css/04-memory.css">
<link rel="stylesheet" href="/static/css/05-documents.css">
<link rel="stylesheet" href="/static/css/06-admin-settings.css">
<link rel="stylesheet" href="/static/css/07-documents.css">
<link rel="stylesheet" href="/static/css/08-skills.css">
<link rel="stylesheet" href="/static/css/09-gallery.css">
<link rel="stylesheet" href="/static/css/10-cookbook.css">
<link rel="stylesheet" href="/static/css/11-tasks.css">
<link rel="stylesheet" href="/static/css/12-gallery.css">
<link rel="stylesheet" href="/static/css/13-image-editor.css">
<link rel="stylesheet" href="/static/css/14-email.css">
<link rel="stylesheet" href="/static/css/15-notes.css">
<link rel="stylesheet" href="/static/css/16-calendar.css">
<link rel="stylesheet" href="/static/css/17-research.css">
<link rel="stylesheet" href="/static/css/documents-gallery-editor.css">
<link rel="stylesheet" href="/static/css/email-calendar-notes-tasks.css">
<link rel="stylesheet" href="/static/css/cookbook-research-memory-settings.css">
</head>
<body>
<!-- Intentionally empty. tests/css_snapshot/capture.mjs mounts one subtree
+16 -3
View File
@@ -55,7 +55,10 @@ function swapRuleOccurrences(css, selector) {
}
const matches = blocks.filter(b => b.selector === selector && b.end !== undefined);
if (matches.length < 2) {
throw new Error(`swap-rule: need two top-level blocks for "${selector}", found ${matches.length}`);
// The selector is not in this sheet, or appears once. The stylesheet is
// split across several files, so that is expected for most of them: the
// caller decides whether any sheet matched at all.
return null;
}
const [a, b] = matches;
const textA = css.slice(a.start, a.end);
@@ -186,6 +189,7 @@ async function main() {
const missing = {};
try {
let swapped = 0;
for (const page of job.pages) {
snapshot[page.name] = {};
let shippedStylesheets = null;
@@ -217,9 +221,15 @@ async function main() {
});
if (job.swapRule) {
await tab.route('**/static/style.css*', async route => {
// The cascade is spread over several files, so find the one that
// actually holds two top-level blocks of the selector and rewrite
// only that one. Every other sheet passes through untouched.
await tab.route('**/static/**/*.css*', async route => {
const response = await route.fetch();
const body = swapRuleOccurrences(await response.text(), job.swapRule);
const original = await response.text();
const body = swapRuleOccurrences(original, job.swapRule);
if (body === null) return route.fulfill({ response, body: original });
swapped += 1;
await route.fulfill({ response, body, headers: { ...response.headers(), 'content-type': 'text/css; charset=utf-8' } });
});
}
@@ -253,6 +263,9 @@ async function main() {
await context.close();
}
}
if (job.swapRule && swapped === 0) {
throw new Error(`swap-rule: no stylesheet had two top-level blocks for "${job.swapRule}"`);
}
} finally {
await browser.close();
}
@@ -1,10 +1,17 @@
const { test, expect } = require('@playwright/test');
async function addAppStyles(page) {
const { stylesheetUrls } = await import('../../helpers/stylesheets.mjs');
for (const url of await stylesheetUrls()) {
await page.addStyleTag({ url });
}
}
test('mobile compare uses tabs to show one mounted pane at a time', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto('/login');
await page.addStyleTag({ url: '/static/style.css?v=20260903comparemodeicons1-emailsettingscards1' });
await addAppStyles(page);
await page.evaluate(async () => {
const { default: state } = await import('/static/js/compare/state.js');
@@ -58,7 +65,7 @@ test('mobile compare uses tabs to show one mounted pane at a time', async ({ pag
test('mobile compare probe keeps feedback below models and actions split', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto('/login');
await page.addStyleTag({ url: '/static/style.css?v=20260903comparemodeicons1-emailsettingscards1' });
await addAppStyles(page);
await page.evaluate(() => {
document.body.innerHTML = `
<div class="compare-probe-overlay">
+67
View File
@@ -0,0 +1,67 @@
import { readFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const HERE = dirname(fileURLToPath(import.meta.url));
const STATIC = join(HERE, '..', '..', 'static');
const INDEX = join(STATIC, 'index.html');
const LINK = /<link\b[^>]*\brel\s*=\s*["']stylesheet["'][^>]*\bhref\s*=\s*["']\/static\/([^"'?]+)([^"']*)["']/gi;
async function entries() {
const html = await readFile(INDEX, 'utf8');
const out = [];
for (const match of html.matchAll(LINK)) {
const rel = match[1];
const query = match[2];
if (rel.startsWith('lib/')) continue;
out.push({
path: join(STATIC, rel),
url: `/static/${rel}${query}`,
});
}
if (!out.length) {
throw new Error(`no app stylesheet <link> tags found in ${INDEX}`);
}
for (const entry of out) {
try {
await readFile(entry.path);
} catch {
throw new Error(
`index.html links a stylesheet that does not exist: ${entry.path}`,
);
}
}
return out;
}
export async function stylesheetPaths() {
return (await entries()).map(entry => entry.path);
}
export async function stylesheetUrls() {
return (await entries()).map(entry => entry.url);
}
export async function stylesheetLinkTags() {
return (await stylesheetUrls())
.map(url => `<link rel="stylesheet" href="${url}">`)
.join('');
}
export async function appCss() {
const paths = await stylesheetPaths();
const parts = [];
for (const path of paths) {
parts.push(await readFile(path, 'utf8'));
}
return parts.join('\n');
}
+28 -7
View File
@@ -1,11 +1,11 @@
"""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.
The former ``static/style.css`` is now an ordered set of numbered fragments,
followed by the existing panel stylesheets. ``static/index.html`` loads the
complete cascade eagerly in the order the browser must apply it.
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
A test that asserts on a rule must therefore look at the complete cascade.
Reading one fragment 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.
"""
@@ -53,11 +53,32 @@ def stylesheet_urls() -> list[str]:
def stylesheet_link_tags() -> str:
"""The <link> tags to drop into a synthetic page so it gets the whole
cascade, not just style.css."""
"""The <link> tags for a synthetic page that needs the whole cascade."""
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())
def stylesheet_cache_version() -> str:
"""The single ``?v=`` token every app stylesheet link carries.
The stylesheet is split across several files that must be busted together:
shipping one fragment under a stale token serves a browser half of an old
cascade and half of a new one. Tests ask for the shared version here instead of deriving it from one
stylesheet filename, so they keep checking the invariant
rather than a filename.
"""
versions = set()
for url in stylesheet_urls():
m = re.search(r"\?v=([^&]+)$", url)
if not m:
raise AssertionError(f"app stylesheet has no cache-bust token: {url}")
versions.add(m.group(1))
if len(versions) != 1:
raise AssertionError(
f"app stylesheets disagree on their cache-bust token: {sorted(versions)}"
)
return versions.pop()
+10 -7
View File
@@ -1,10 +1,10 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { appCss } from './helpers/stylesheets.mjs';
import { chromium } from 'playwright';
test('research primary actions use compact mobile sizing and retain desktop sizing', async () => {
const css = await readFile(new URL('../static/style.css', import.meta.url), 'utf8');
test('research primary actions retain shipped cascade sizing across mobile and desktop', async () => {
const css = await appCss();
const browser = await chromium.launch({ headless: true });
try {
const page = await browser.newPage();
@@ -25,10 +25,13 @@ test('research primary actions use compact mobile sizing and retain desktop sizi
}));
for (const button of buttons) {
if (width <= 600) {
assert.equal(button.width, 24);
assert.equal(button.height, 22);
assert.equal(button.icon, 10);
assert.equal(button.labelHidden, true);
// Assert the complete shipped cascade, not the historical
// style.css-only result. Later app styles keep the action labels
// visible and use the larger mobile control geometry.
assert.ok(button.width > 24);
assert.equal(button.height, 28);
assert.equal(button.icon, 13);
assert.equal(button.labelHidden, false);
} else {
assert.equal(button.height, 20);
assert.equal(button.icon, 10);
+1 -1
View File
@@ -100,7 +100,7 @@ def test_model_picker_source_invariants():
def test_composer_reasoning_effort_ui_markup():
"""Verify static/index.html and static/style.css include reasoning effort controls."""
"""Verify static/index.html and the app stylesheet cascade include reasoning effort controls."""
html = (ROOT / "static/index.html").read_text(encoding="utf-8")
css = app_css()
# HTML elements
+2 -3
View File
@@ -1,6 +1,6 @@
from pathlib import Path
import re
from tests.helpers.stylesheets import app_css
from tests.helpers.stylesheets import app_css, stylesheet_cache_version
ROOT = Path(__file__).resolve().parent.parent
@@ -65,9 +65,8 @@ def test_compare_cache_key_bumped_for_shuffle_notice():
index = _read("static/js/compare/index.js")
app_versions = re.findall(r"/static/app\.js\?v=([A-Za-z0-9_-]+)", html)
style_version = re.search(r"/static/style\.css\?v=([A-Za-z0-9_-]+)", html)
assert app_versions and len(set(app_versions)) == 1
assert style_version and style_version.group(1) == app_versions[0]
assert stylesheet_cache_version() == app_versions[0]
assert re.search(r"compare/index\.js\?v=[A-Za-z0-9_-]+", app)
assert re.search(r"vote\.js\?v=[A-Za-z0-9_-]+", index)
assert re.search(r"panes\.js\?v=[A-Za-z0-9_-]+", index)
+4 -4
View File
@@ -1,7 +1,7 @@
"""Computed-style snapshot regression for ``static/style.css``.
"""Computed-style snapshot regression for the shipped app CSS cascade.
The stylesheet is one 51k-line file whose result depends on source order, so an
extraction that "looks fine" can still change which declaration wins. These
The stylesheet is an ordered multi-file cascade whose result depends on source
order, so a move that "looks fine" can still change which declaration wins. These
tests capture ``getComputedStyle`` over a fixed element inventory across pages,
viewports, themes and density modes, and compare the hash against
``tests/css_snapshot/baseline.json``.
@@ -38,7 +38,7 @@ def static_origin() -> str:
# One conflicting selector used to prove the harness is actually sensitive to
# source order. `.attach-strip` is declared three times at the top level of
# style.css with different margin, min-height and padding, so swapping the
# cascade with different margin, min-height and padding, so swapping the
# first two changes which declaration wins without changing a single byte of
# any individual rule.
CONFLICTING_SELECTOR = ".attach-strip"
+2 -2
View File
@@ -1,7 +1,7 @@
from pathlib import Path
import re
from tests.helpers.stylesheets import app_css
from tests.helpers.stylesheets import app_css, stylesheet_cache_version
ROOT = Path(__file__).resolve().parent.parent
@@ -49,7 +49,7 @@ def test_gallery_static_cache_key_bumped():
app = _read("static/app.js")
service_worker = _read("static/sw.js")
assert re.search(r"/static/style\.css\?v=[A-Za-z0-9_-]+", html)
assert stylesheet_cache_version()
html_version = re.search(r"/static/js/gallery\.js\?v=([A-Za-z0-9_-]+)", html)
app_version = re.search(r"gallery\.js\?v=([A-Za-z0-9_-]+)", app)
sw_version = re.search(r"/static/js/gallery\.js\?v=([A-Za-z0-9_-]+)", service_worker)
@@ -1,7 +1,7 @@
from pathlib import Path
import re
from tests.helpers.stylesheets import app_css
from tests.helpers.stylesheets import app_css, stylesheet_cache_version
ROOT = Path(__file__).resolve().parent.parent
@@ -54,9 +54,8 @@ def test_notes_mobile_checklist_asset_versions_are_bumped():
assert re.search(r"notes\.js\?v=[A-Za-z0-9_-]+", app)
app_versions = re.findall(r"/static/app\.js\?v=([A-Za-z0-9_-]+)", html)
style_version = re.search(r"/static/style\.css\?v=([A-Za-z0-9_-]+)", html)
assert app_versions and len(set(app_versions)) == 1
assert style_version and style_version.group(1) == app_versions[0]
assert stylesheet_cache_version() == app_versions[0]
def test_drawing_edits_mark_notes_dirty_and_keep_one_gallery_image():
+2 -3
View File
@@ -3,7 +3,7 @@
from pathlib import Path
import re
from tests.helpers.stylesheets import app_css
from tests.helpers.stylesheets import app_css, stylesheet_cache_version
ROOT = Path(__file__).resolve().parent.parent
@@ -78,6 +78,5 @@ def test_research_panel_uses_one_versioned_module_instance():
panel_version = re.search(r"research/panel\.js\?v=([^'\"]+)", app)
asset_version = re.search(r"/static/app\.js\?v=([^'\"]+)", index)
style_version = re.search(r"/static/style\.css\?v=([^'\"]+)", index)
assert panel_version and renderer.count(f"research/panel.js?v={panel_version.group(1)}") == 2
assert asset_version and style_version and asset_version.group(1) == style_version.group(1)
assert asset_version and asset_version.group(1) == stylesheet_cache_version()
+11 -2
View File
@@ -55,14 +55,23 @@ def _local_path(url):
return ROOT / url.split("?", 1)[0].lstrip("/")
# HTML outside static/ that still links app stylesheets by URL. The snapshot
# bench is one: it is not shipped, but a dead link there renders the bench
# unstyled and every computed-style measurement taken from it is worthless.
_UNSHIPPED_HTML = ("tests/css_snapshot/bench.html",)
def test_every_stylesheet_referenced_by_shipped_html_exists():
checked = 0
for html_path in sorted((ROOT / "static").glob("*.html")):
html_files = sorted((ROOT / "static").glob("*.html"))
html_files += [ROOT / rel for rel in _UNSHIPPED_HTML]
for html_path in html_files:
for href in _stylesheet_hrefs(html_path):
if not href.startswith("/"):
continue # external or relative-to-page; not ours to resolve
assert _local_path(href).is_file(), (
f"{html_path.name} links {href}, which does not exist on disk"
f"{html_path.relative_to(ROOT)} links {href}, "
"which does not exist on disk"
)
checked += 1
assert checked, "no app stylesheet links found - the parser or the markup moved"
+21 -24
View File
@@ -1,9 +1,9 @@
"""Tests must reason about the whole cascade, not one file of it.
``static/style.css`` is being decomposed. A test that reads that file alone,
or builds a synthetic page linking only that file, silently loses every rule
that has moved: it keeps passing while covering less. Both mistakes existed
and are cheap to detect, so this fails on either.
The former ``static/style.css`` has been decomposed into an ordered cascade.
A test that names the deleted file as a runtime stylesheet silently loses the
split cascade. Python, JavaScript, MJS and HTML test sources are all scanned
recursively so nested browser tests cannot escape this guard.
"""
import re
@@ -17,12 +17,20 @@ SELF = Path(__file__).name
# set itself, so naming the file is the point rather than a mistake.
ALLOWED = {SELF, "test_static_stylesheet_manifest.py", "test_css_computed_style_snapshot.py"}
_DIRECT_READ = re.compile(r'["\']static/style\.css["\']|"static"\s*/\s*"style\.css"')
_LONE_LINK = re.compile(r'<link[^>]*href="/static/style\.css')
_DELETED_STYLE_LITERAL = re.compile(
r'''["'][^"'\n]*static/style\.css[^"'\n]*["']'''
)
_SUFFIXES = {".py", ".js", ".mjs", ".html"}
def _test_sources():
return [p for p in sorted((ROOT / "tests").glob("*.py")) if p.name not in ALLOWED]
return [
p
for p in sorted((ROOT / "tests").rglob("*"))
if p.is_file()
and p.suffix in _SUFFIXES
and p.name not in ALLOWED
]
def test_sources_are_discoverable() -> None:
@@ -30,25 +38,14 @@ def test_sources_are_discoverable() -> None:
assert len(_test_sources()) > 100
def test_no_test_reads_style_css_as_the_whole_cascade() -> None:
def test_no_test_names_deleted_style_css_as_a_runtime_asset() -> None:
offenders = [
p.name for p in _test_sources()
if _DIRECT_READ.search(p.read_text(encoding="utf-8"))
str(p.relative_to(ROOT))
for p in _test_sources()
if _DELETED_STYLE_LITERAL.search(p.read_text(encoding="utf-8"))
]
assert offenders == [], (
"read the cascade with tests.helpers.stylesheets.app_css() instead of "
f"static/style.css alone: {offenders}"
)
def test_no_synthetic_page_links_style_css_alone() -> None:
offenders = [
p.name for p in _test_sources()
if _LONE_LINK.search(p.read_text(encoding="utf-8"))
]
assert offenders == [], (
"build synthetic pages with tests.helpers.stylesheets.stylesheet_link_tags() "
f"so they get every stylesheet index.html loads: {offenders}"
"tests must load the app stylesheet cascade instead of the deleted "
f"static/style.css asset: {offenders}"
)