mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-09 23:47:46 +00:00
merge: sync lab after stylesheet split
This commit is contained in:
commit
9ee9205bfc
45 changed files with 48107 additions and 47640 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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
static/css/00-tokens.css
Normal file
183
static/css/00-tokens.css
Normal 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; }
|
||||
7807
static/css/01-agent-chat.css
Normal file
7807
static/css/01-agent-chat.css
Normal file
File diff suppressed because it is too large
Load diff
1805
static/css/02-compare.css
Normal file
1805
static/css/02-compare.css
Normal file
File diff suppressed because it is too large
Load diff
2846
static/css/03-agent-chat.css
Normal file
2846
static/css/03-agent-chat.css
Normal file
File diff suppressed because it is too large
Load diff
1675
static/css/04-memory.css
Normal file
1675
static/css/04-memory.css
Normal file
File diff suppressed because it is too large
Load diff
3199
static/css/05-documents.css
Normal file
3199
static/css/05-documents.css
Normal file
File diff suppressed because it is too large
Load diff
1426
static/css/06-admin-settings.css
Normal file
1426
static/css/06-admin-settings.css
Normal file
File diff suppressed because it is too large
Load diff
1283
static/css/07-documents.css
Normal file
1283
static/css/07-documents.css
Normal file
File diff suppressed because it is too large
Load diff
1088
static/css/08-skills.css
Normal file
1088
static/css/08-skills.css
Normal file
File diff suppressed because it is too large
Load diff
1382
static/css/09-gallery.css
Normal file
1382
static/css/09-gallery.css
Normal file
File diff suppressed because it is too large
Load diff
4324
static/css/10-cookbook.css
Normal file
4324
static/css/10-cookbook.css
Normal file
File diff suppressed because it is too large
Load diff
1972
static/css/11-tasks.css
Normal file
1972
static/css/11-tasks.css
Normal file
File diff suppressed because it is too large
Load diff
839
static/css/12-gallery.css
Normal file
839
static/css/12-gallery.css
Normal 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;
|
||||
}
|
||||
4823
static/css/13-image-editor.css
Normal file
4823
static/css/13-image-editor.css
Normal file
File diff suppressed because it is too large
Load diff
4729
static/css/14-email.css
Normal file
4729
static/css/14-email.css
Normal file
File diff suppressed because it is too large
Load diff
4062
static/css/15-notes.css
Normal file
4062
static/css/15-notes.css
Normal file
File diff suppressed because it is too large
Load diff
1175
static/css/16-calendar.css
Normal file
1175
static/css/16-calendar.css
Normal file
File diff suppressed because it is too large
Load diff
3184
static/css/17-research.css
Normal file
3184
static/css/17-research.css
Normal file
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
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;';
|
||||
|
|
|
|||
|
|
@ -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
static/style.css
47512
static/style.css
File diff suppressed because it is too large
Load diff
29
static/sw.js
29
static/sw.js
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
tests/helpers/stylesheets.mjs
Normal file
67
tests/helpers/stylesheets.mjs
Normal 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');
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue