@open-press/core 1.3.1 → 1.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-press/core",
3
- "version": "1.3.1",
3
+ "version": "1.3.2",
4
4
  "type": "module",
5
5
  "description": "open-press core — runtime primitives, CLI, and render pipeline for AI-first fixed-layout documents.",
6
6
  "license": "MIT",
@@ -221,16 +221,27 @@ export function OpenPressApp() {
221
221
  const presentationSlug = state.activeSlug || currentRouteFromLocation().slug;
222
222
  const openPresentation = state.document.meta.type === "slides" && presentationSlug
223
223
  ? (pageIndex: number) => {
224
+ // requestFullscreen must be called synchronously within the user gesture.
225
+ // window.open() creates a new browsing context, so the user gesture is lost
226
+ // and the browser blocks fullscreen. Navigate in-place instead.
227
+ const root = globalThis.document?.documentElement;
228
+ if (root?.requestFullscreen) void root.requestFullscreen().catch(() => {});
224
229
  const slug = normalizeSlug(presentationSlug);
225
- const pathname = slug ? `/${slug}` : "/";
226
- const hash = `#page-${String(pageIndex + 1).padStart(2, "0")}`;
227
- window.open(`${pathname}?fullscreen=1${hash}`, "_blank", "noopener,noreferrer");
230
+ pushPressRoute(slug, "present", pageIndex);
231
+ setState((latest) => latest.status === "ready"
232
+ ? { ...latest, runtimeMode: "present" }
233
+ : latest);
228
234
  }
229
235
  : undefined;
230
236
 
231
237
  const exitPresentation = state.document.meta.type === "slides"
232
238
  ? (pageIndex: number) => {
233
239
  if (state.status !== "ready") return;
240
+ // Exit fullscreen before returning to the workbench.
241
+ const activeDoc = globalThis.document;
242
+ if (activeDoc?.fullscreenElement && activeDoc?.exitFullscreen) {
243
+ void activeDoc.exitFullscreen().catch(() => {});
244
+ }
234
245
  const slug = state.activeSlug || currentRouteFromLocation().slug;
235
246
  if (slug) pushPressRoute(slug, "preview", pageIndex);
236
247
  setState((latest) => latest.status === "ready"
@@ -1,49 +1,90 @@
1
1
  import { useEffect, useRef, useState, type CSSProperties, type KeyboardEvent } from "react";
2
2
  import type { HtmlPageBlock, ReaderDocument, WorkspaceManifest, WorkspaceManifestPress } from "../document-model";
3
3
 
4
+ type GalleryFilter = "all" | "pages" | "slides";
5
+
4
6
  interface Props {
5
7
  manifest: WorkspaceManifest;
6
- // Called when the reader navigates into a specific Press. The host
7
- // is responsible for routing (history.pushState, hash, etc.); the
8
- // gallery just emits the chosen slug.
9
8
  onSelectPress: (press: WorkspaceManifestPress) => void;
10
9
  }
11
10
 
12
- // Reader landing page for multi-Press workspaces. Shows a Figma-style
13
- // uniform-grid card per Press; each card lazily loads that Press's
14
- // document.json and renders the first page as a thumbnail preview.
15
- // Single-Press workspaces skip the gallery entirely.
16
11
  export function WorkspaceGalleryPage({ manifest, onSelectPress }: Props) {
17
12
  const heading = manifest.name ?? "Workspace";
18
- const pressCount = String(manifest.presses.length).padStart(2, "0");
13
+ const [filter, setFilter] = useState<GalleryFilter>("all");
14
+
15
+ const counts = {
16
+ all: manifest.presses.length,
17
+ pages: manifest.presses.filter((p) => p.type === "pages").length,
18
+ slides: manifest.presses.filter((p) => p.type === "slides").length,
19
+ };
20
+
21
+ const visiblePresses = filter === "all"
22
+ ? manifest.presses
23
+ : manifest.presses.filter((p) => p.type === filter);
19
24
 
20
25
  return (
21
26
  <main className="openpress-workspace-gallery" aria-labelledby="workspace-gallery-heading">
22
27
  <header className="openpress-workspace-gallery__header">
23
28
  <div className="openpress-workspace-gallery__headline">
24
- <p className="openpress-workspace-gallery__eyebrow">Workspace</p>
29
+ <p className="openpress-workspace-gallery__brand">
30
+ <span className="openpress-workspace-gallery__brand-mark">open-press</span>
31
+ <span className="openpress-workspace-gallery__brand-sep" aria-hidden="true">/</span>
32
+ <span className="openpress-workspace-gallery__eyebrow">Workspace</span>
33
+ </p>
25
34
  <h1 id="workspace-gallery-heading">{heading}</h1>
26
35
  </div>
27
- <p className="openpress-workspace-gallery__count">
28
- <span>{pressCount}</span>
29
- <small>{manifest.presses.length === 1 ? "document" : "documents"}</small>
30
- </p>
31
36
  </header>
32
37
 
33
- <ul className="openpress-workspace-gallery__grid" role="list">
34
- {manifest.presses.map((press) => (
35
- <li key={press.slug || "root"} className="openpress-workspace-gallery__item">
36
- <PressCard press={press} onSelect={() => onSelectPress(press)} />
37
- </li>
38
- ))}
39
- </ul>
38
+ <div className="openpress-workspace-gallery__body">
39
+ <nav className="openpress-workspace-gallery__sidebar" aria-label="文件類型篩選">
40
+ <FilterButton label="All" count={counts.all} active={filter === "all"} onClick={() => setFilter("all")} />
41
+ <FilterButton label="Pages" count={counts.pages} active={filter === "pages"} onClick={() => setFilter("pages")} />
42
+ <FilterButton label="Slides" count={counts.slides} active={filter === "slides"} onClick={() => setFilter("slides")} />
43
+ </nav>
44
+
45
+ <section className="openpress-workspace-gallery__main" aria-label={`${filter} 文件`}>
46
+ {visiblePresses.length > 0 ? (
47
+ <ul className="openpress-workspace-gallery__grid" role="list">
48
+ {visiblePresses.map((press) => (
49
+ <li key={press.slug || "root"} className="openpress-workspace-gallery__item">
50
+ <PressCard press={press} onSelect={() => onSelectPress(press)} />
51
+ </li>
52
+ ))}
53
+ </ul>
54
+ ) : (
55
+ <p className="openpress-workspace-gallery__empty">No {filter} documents.</p>
56
+ )}
57
+ </section>
58
+ </div>
40
59
  </main>
41
60
  );
42
61
  }
43
62
 
44
- // Card is a div+role=button (not <button>) so it can contain the
45
- // rendered page HTML — buttons may only hold phrasing content, and
46
- // page HTML is block-level.
63
+ function FilterButton({
64
+ label,
65
+ count,
66
+ active,
67
+ onClick,
68
+ }: {
69
+ label: string;
70
+ count: number;
71
+ active: boolean;
72
+ onClick: () => void;
73
+ }) {
74
+ return (
75
+ <button
76
+ type="button"
77
+ className="openpress-workspace-gallery__filter-btn"
78
+ aria-pressed={active}
79
+ data-active={active ? "true" : "false"}
80
+ onClick={onClick}
81
+ >
82
+ <span className="openpress-workspace-gallery__filter-label">{label}</span>
83
+ <span className="openpress-workspace-gallery__filter-count">{String(count).padStart(2, "0")}</span>
84
+ </button>
85
+ );
86
+ }
87
+
47
88
  function PressCard({ press, onSelect }: { press: WorkspaceManifestPress; onSelect: () => void }) {
48
89
  const handleKey = (event: KeyboardEvent<HTMLDivElement>) => {
49
90
  if (event.key === "Enter" || event.key === " ") {
@@ -62,7 +103,7 @@ function PressCard({ press, onSelect }: { press: WorkspaceManifestPress; onSelec
62
103
  aria-label={`Open ${press.title}`}
63
104
  >
64
105
  <PressThumbnail press={press} />
65
- <div className="openpress-workspace-gallery__body">
106
+ <div className="openpress-workspace-gallery__card-body">
66
107
  <div className="openpress-workspace-gallery__title">{press.title}</div>
67
108
  <div className="openpress-workspace-gallery__meta">
68
109
  {press.slug ? <span className="openpress-workspace-gallery__slug">{press.slug}</span> : null}
@@ -78,9 +119,6 @@ function PressCard({ press, onSelect }: { press: WorkspaceManifestPress; onSelec
78
119
  function PressThumbnail({ press }: { press: WorkspaceManifestPress }) {
79
120
  const [state, setState] = useState<ThumbnailState>({ status: "loading" });
80
121
 
81
- // Lazy-load each Press's document.json so the gallery doesn't block
82
- // on a network waterfall when there are many Press. Errors degrade
83
- // to the geometry-only placeholder used by the loading state.
84
122
  useEffect(() => {
85
123
  let cancelled = false;
86
124
  fetchFirstPage(press.documentUrl).then((page) => {
@@ -92,9 +130,6 @@ function PressThumbnail({ press }: { press: WorkspaceManifestPress }) {
92
130
  return () => { cancelled = true; };
93
131
  }, [press.documentUrl]);
94
132
 
95
- // Outer card is uniform 4:3 (set in CSS). The page itself letterboxes
96
- // inside via centered scale, so A4 portrait renders tall-and-narrow,
97
- // social square renders centered, 16:9 slide stretches edge-to-edge.
98
133
  return (
99
134
  <div className="openpress-workspace-gallery__thumb" aria-hidden="true">
100
135
  {state.status === "ready" ? (
@@ -147,10 +182,6 @@ function PageMiniature({ page, press }: { page: HtmlPageBlock; press: WorkspaceM
147
182
  visibility: scale ? "visible" : "hidden",
148
183
  };
149
184
 
150
- // Match the wrapping used by PublicReaderPage so scoped CSS targeting
151
- // `.openpress-html-page__html` selectors lights up identically. The
152
- // outer frame owns centering; the page only scales from its top-left
153
- // origin, which avoids mixed translate/scale centering drift.
154
185
  const pageStyle: CSSProperties = {
155
186
  "--openpress-page-width": `${pageWidthPx}px`,
156
187
  "--openpress-page-height": `${pageHeightPx}px`,
@@ -172,7 +203,6 @@ function PageMiniature({ page, press }: { page: HtmlPageBlock; press: WorkspaceM
172
203
  <div className={pageClass} style={pageStyle} data-openpress-thumb-page="true">
173
204
  <div
174
205
  className="openpress-html-page__html"
175
- // Trusted HTML — same source as the reader's main render path.
176
206
  dangerouslySetInnerHTML={{ __html: page.html }}
177
207
  />
178
208
  </div>
@@ -198,10 +228,6 @@ async function fetchFirstPage(url: string): Promise<HtmlPageBlock | null> {
198
228
  }
199
229
  }
200
230
 
201
- // Convert a CSS length string (px / mm / cm / in) into device pixels
202
- // at 96 dpi. A4 pages are stored as "210mm" / "297mm" so the gallery
203
- // and thumbnail scalers need this to compute their fit ratio — using
204
- // the bare string would otherwise resolve to the default asset path.
205
231
  function parsePxLength(value: string | undefined): number | null {
206
232
  if (!value) return null;
207
233
  const match = value.trim().match(/^([\d.]+)\s*(px|mm|cm|in)$/i);
@@ -29,9 +29,13 @@ export function SlidePresentationPage({
29
29
  if (typeof window === "undefined") return 0;
30
30
  return pageIndexFromHash(window.location.hash, normalizedPageCount) ?? 0;
31
31
  });
32
- const [uiMode, setUiMode] = useState<PresentationUiMode>(() => (
33
- shouldStartImmersive() ? "immersive" : "chrome"
34
- ));
32
+ const [uiMode, setUiMode] = useState<PresentationUiMode>(() => {
33
+ if (shouldStartImmersive()) return "immersive";
34
+ // Fullscreen may already be active if requestFullscreen() was called
35
+ // synchronously in the workbench click handler before in-place navigation.
36
+ if (typeof globalThis.document !== "undefined" && globalThis.document.fullscreenElement) return "immersive";
37
+ return "chrome";
38
+ });
35
39
  const pageViewport = usePageViewportScale({
36
40
  stageRef,
37
41
  pageContainerRef: sourceContainerRef,
@@ -98,36 +98,9 @@ export function WorkbenchToolbarActions({
98
98
  </button>
99
99
  </div>
100
100
  ) : null}
101
- <div className="openpress-workbench-toolbar__group" aria-label="匯出">
102
- <ExportControl
103
- pages={pages}
104
- currentPageIndex={currentPageIndex}
105
- pressTitle={pressTitle}
106
- theme={theme}
107
- onExportPdf={onExportPdf}
108
- pdfDisabled={pdfDisabled}
109
- pdfLabel={pdfLabel}
110
- pdfStatusMessage={pdfStatusMessage}
111
- pdfActionStatus={pdfActionStatus}
112
- />
113
- </div>
114
- <div className="openpress-workbench-toolbar__group openpress-workbench-toolbar__group--page" aria-label="頁面規格">
115
- {isSlidePress && onOpenPresentation ? (
116
- <button
117
- type="button"
118
- className="openpress-workbench-toolbar-action"
119
- data-openpress-slide-present
120
- data-openpress-toolbar-expanded="false"
121
- data-openpress-toolbar-active="false"
122
- aria-pressed="false"
123
- title="進入放映模式"
124
- aria-label="進入放映模式"
125
- onClick={() => onOpenPresentation(currentPageIndex)}
126
- >
127
- <Play aria-hidden="true" />
128
- <span className="openpress-workbench-toolbar-action__label">放映</span>
129
- </button>
130
- ) : null}
101
+
102
+ {/* Center group: page geometry / zoom + workspace tools */}
103
+ <div className="openpress-workbench-toolbar__group openpress-workbench-toolbar__group--page" aria-label="頁面規格與工具">
131
104
  <button
132
105
  type="button"
133
106
  className="openpress-workbench-page-geometry"
@@ -146,25 +119,15 @@ export function WorkbenchToolbarActions({
146
119
  onScaleModeChange={onScaleModeChange}
147
120
  onPageLayoutModeChange={onPageLayoutModeChange}
148
121
  />
149
- </div>
150
- <div className="openpress-workbench-toolbar__group openpress-workbench-toolbar__group--right" aria-label="工作台狀態與發布">
122
+ {workspaceMode ? (
123
+ <span className="openpress-workbench-toolbar__sep" aria-hidden="true" />
124
+ ) : null}
151
125
  {workspaceMode ? (
152
126
  <SearchControl
153
127
  sourceBlocksByPath={sourceBlocksByPath}
154
128
  onSelectPage={onSelectPage}
155
129
  />
156
130
  ) : null}
157
- {workspaceMode && editStatusMessage ? (
158
- <span
159
- className="openpress-dev-edit-status openpress-dev-edit-status--toolbar"
160
- data-openpress-edit-status={inlineEditStatus.state}
161
- role="status"
162
- aria-live="polite"
163
- >
164
- {inlineEditStatus.state === "saving" ? <span className="openpress-dev-edit-status__spinner" aria-hidden="true" /> : null}
165
- <span>{editStatusMessage}</span>
166
- </span>
167
- ) : null}
168
131
  {workspaceMode ? (
169
132
  <button
170
133
  type="button"
@@ -183,6 +146,17 @@ export function WorkbenchToolbarActions({
183
146
  <span className="openpress-dev-inspector-status">{inspectorSelectionLabel}</span>
184
147
  </button>
185
148
  ) : null}
149
+ {workspaceMode && editStatusMessage ? (
150
+ <span
151
+ className="openpress-dev-edit-status openpress-dev-edit-status--toolbar"
152
+ data-openpress-edit-status={inlineEditStatus.state}
153
+ role="status"
154
+ aria-live="polite"
155
+ >
156
+ {inlineEditStatus.state === "saving" ? <span className="openpress-dev-edit-status__spinner" aria-hidden="true" /> : null}
157
+ <span>{editStatusMessage}</span>
158
+ </span>
159
+ ) : null}
186
160
  {workspaceMode && inspectorMode ? (
187
161
  <span
188
162
  className="openpress-dev-inspector-status"
@@ -193,6 +167,21 @@ export function WorkbenchToolbarActions({
193
167
  {inspectorCommentStatusMessage}
194
168
  </span>
195
169
  ) : null}
170
+ </div>
171
+
172
+ {/* Right group: export + deploy + present */}
173
+ <div className="openpress-workbench-toolbar__group openpress-workbench-toolbar__group--right" aria-label="匯出與發布">
174
+ <ExportControl
175
+ pages={pages}
176
+ currentPageIndex={currentPageIndex}
177
+ pressTitle={pressTitle}
178
+ theme={theme}
179
+ onExportPdf={onExportPdf}
180
+ pdfDisabled={pdfDisabled}
181
+ pdfLabel={pdfLabel}
182
+ pdfStatusMessage={pdfStatusMessage}
183
+ pdfActionStatus={pdfActionStatus}
184
+ />
196
185
  {localDeployEnabled ? (
197
186
  <DeploymentControl
198
187
  info={deploymentInfo}
@@ -200,6 +189,20 @@ export function WorkbenchToolbarActions({
200
189
  onDeploy={onDeploy}
201
190
  />
202
191
  ) : null}
192
+ {isSlidePress && onOpenPresentation ? (
193
+ <button
194
+ type="button"
195
+ className="openpress-workbench-toolbar-action openpress-workbench-toolbar-action--primary"
196
+ data-openpress-slide-present
197
+ aria-pressed="false"
198
+ title="進入放映模式"
199
+ aria-label="進入放映模式"
200
+ onClick={() => onOpenPresentation(currentPageIndex)}
201
+ >
202
+ <Play aria-hidden="true" />
203
+ <span className="openpress-workbench-toolbar-action__label">放映</span>
204
+ </button>
205
+ ) : null}
203
206
  </div>
204
207
  </>
205
208
  );
@@ -235,6 +235,15 @@
235
235
  height: 1px;
236
236
  }
237
237
 
238
+ .openpress-workbench-toolbar__sep {
239
+ display: block;
240
+ width: 1px;
241
+ height: 16px;
242
+ flex: 0 0 auto;
243
+ background: rgb(255 255 255 / 10%);
244
+ border-radius: 1px;
245
+ }
246
+
238
247
  .openpress-workbench-toolbar-action {
239
248
  position: relative;
240
249
  display: inline-flex;
@@ -279,6 +288,30 @@
279
288
  opacity: 0.62;
280
289
  }
281
290
 
291
+ .openpress-workbench-toolbar-action--primary {
292
+ width: auto;
293
+ min-width: 30px;
294
+ max-width: min(34vw, 300px);
295
+ gap: 7px;
296
+ padding: 0 12px;
297
+ background: var(--openpress-accent, #df4b21);
298
+ border-color: transparent;
299
+ color: #fff;
300
+ }
301
+
302
+ .openpress-workbench-toolbar-action--primary .openpress-workbench-toolbar-action__label {
303
+ display: inline-flex;
304
+ }
305
+
306
+ .openpress-workbench-toolbar-action--primary:hover:not(:disabled) {
307
+ background: color-mix(in srgb, var(--openpress-accent, #df4b21) 82%, #fff);
308
+ color: #fff;
309
+ }
310
+
311
+ .openpress-workbench-toolbar-action--primary:active:not(:disabled) {
312
+ transform: translateY(1px);
313
+ }
314
+
282
315
  .openpress-workbench-toolbar-action[data-openpress-toolbar-expanded="true"] {
283
316
  width: auto;
284
317
  min-width: 30px;
@@ -4,10 +4,8 @@
4
4
  load the document directly, so this CSS is dormant until users
5
5
  add a second <Press> to their <Workspace>.
6
6
 
7
- Layout intent: Figma-style file grid uniform card width, fixed
8
- thumbnail aspect ratio (4:3), filename + meta below. Each card
9
- loads its first page asynchronously and renders it scaled-down
10
- inside the thumbnail slot. */
7
+ Layout: full-height dark page, header + two-column body
8
+ (narrow left sidebar for type filter, fluid right grid). */
11
9
 
12
10
  .openpress-workspace-gallery {
13
11
  --workspace-bg: #10110f;
@@ -20,10 +18,10 @@
20
18
  --workspace-card-muted: #65635d;
21
19
  --workspace-card-line: rgba(20, 20, 17, 0.1);
22
20
  --workspace-card-stage: #e8e5dc;
23
- display: grid;
24
- gap: 2rem;
21
+ --workspace-sidebar-w: 180px;
22
+ display: flex;
23
+ flex-direction: column;
25
24
  min-height: 100vh;
26
- max-width: none;
27
25
  margin: 0;
28
26
  padding: 3.6rem clamp(2rem, 4vw, 4.5rem) 6rem;
29
27
  font-family: var(--openpress-font-body, system-ui, sans-serif);
@@ -31,11 +29,13 @@
31
29
  background:
32
30
  linear-gradient(180deg, var(--workspace-bg-soft), var(--workspace-bg) 42rem),
33
31
  var(--workspace-bg);
32
+ gap: 2.25rem;
34
33
  }
35
34
 
35
+ /* ── Header ──────────────────────────────────────────────── */
36
+
36
37
  .openpress-workspace-gallery__header {
37
- display: grid;
38
- grid-template-columns: minmax(0, 1fr) auto;
38
+ display: flex;
39
39
  align-items: end;
40
40
  justify-content: space-between;
41
41
  gap: 2.5rem;
@@ -48,50 +48,114 @@
48
48
  gap: 0.75rem;
49
49
  }
50
50
 
51
- .openpress-workspace-gallery__eyebrow {
51
+ .openpress-workspace-gallery__brand {
52
+ display: flex;
53
+ align-items: center;
54
+ gap: 0.5rem;
52
55
  margin: 0;
53
- color: var(--workspace-muted);
54
56
  font-family: var(--openpress-font-mono, ui-monospace, monospace);
55
57
  font-size: 0.68rem;
56
58
  font-weight: 600;
57
- letter-spacing: 0.16em;
59
+ letter-spacing: 0.12em;
58
60
  text-transform: uppercase;
59
61
  }
60
62
 
63
+ .openpress-workspace-gallery__brand-mark {
64
+ color: var(--workspace-ink);
65
+ }
66
+
67
+ .openpress-workspace-gallery__brand-sep {
68
+ color: var(--workspace-muted);
69
+ letter-spacing: 0;
70
+ }
71
+
72
+ .openpress-workspace-gallery__eyebrow {
73
+ color: var(--workspace-muted);
74
+ }
75
+
61
76
  .openpress-workspace-gallery__header h1 {
62
77
  margin: 0;
63
78
  font-family: var(--openpress-font-display, var(--openpress-font-body, system-ui));
64
- font-size: clamp(2.6rem, 5.4vw, 5.2rem);
65
- font-weight: 720;
66
- line-height: 0.94;
67
- letter-spacing: -0.035em;
79
+ font-size: clamp(1.4rem, 2.6vw, 2.2rem);
80
+ font-weight: 600;
81
+ line-height: 1.1;
82
+ letter-spacing: -0.02em;
68
83
  color: var(--workspace-ink);
69
84
  }
70
85
 
71
- .openpress-workspace-gallery__count {
72
- margin: 0;
86
+ /* ── Body: sidebar + grid ────────────────────────────────── */
87
+
88
+ .openpress-workspace-gallery__body {
73
89
  display: grid;
74
- justify-items: end;
75
- gap: 0.25rem;
76
- min-width: 4.5rem;
90
+ grid-template-columns: var(--workspace-sidebar-w) 1fr;
91
+ align-items: start;
92
+ gap: 2.5rem;
93
+ }
94
+
95
+ /* ── Left sidebar ────────────────────────────────────────── */
96
+
97
+ .openpress-workspace-gallery__sidebar {
98
+ display: flex;
99
+ flex-direction: column;
100
+ gap: 2px;
101
+ position: sticky;
102
+ top: 1.5rem;
103
+ }
104
+
105
+ .openpress-workspace-gallery__filter-btn {
106
+ display: flex;
107
+ align-items: center;
108
+ justify-content: space-between;
109
+ gap: 0.6rem;
110
+ width: 100%;
111
+ padding: 0.52rem 0.75rem;
112
+ border: 1px solid transparent;
113
+ border-radius: 7px;
114
+ background: transparent;
115
+ color: var(--workspace-muted);
116
+ font-family: var(--openpress-font-body, system-ui, sans-serif);
117
+ font-size: 0.82rem;
118
+ font-weight: 500;
119
+ text-align: left;
120
+ cursor: pointer;
121
+ transition:
122
+ background 140ms ease,
123
+ color 140ms ease,
124
+ border-color 140ms ease;
125
+ }
126
+
127
+ .openpress-workspace-gallery__filter-btn:hover {
128
+ background: rgba(244, 241, 232, 0.06);
77
129
  color: var(--workspace-ink);
78
- font-family: var(--openpress-font-mono, ui-monospace, monospace);
79
- line-height: 1;
80
130
  }
81
131
 
82
- .openpress-workspace-gallery__count span {
132
+ .openpress-workspace-gallery__filter-btn[data-active="true"] {
133
+ background: rgba(244, 241, 232, 0.1);
134
+ border-color: rgba(244, 241, 232, 0.14);
83
135
  color: var(--workspace-ink);
84
- font-size: 2rem;
85
- font-weight: 500;
86
- letter-spacing: -0.04em;
87
136
  }
88
137
 
89
- .openpress-workspace-gallery__count small {
138
+ .openpress-workspace-gallery__filter-label {
139
+ flex: 1 1 auto;
140
+ }
141
+
142
+ .openpress-workspace-gallery__filter-count {
143
+ flex: 0 0 auto;
144
+ font-family: var(--openpress-font-mono, ui-monospace, monospace);
145
+ font-size: 0.72rem;
146
+ font-weight: 500;
90
147
  color: var(--workspace-muted);
91
- font-size: 0.62rem;
92
- font-weight: 600;
93
- letter-spacing: 0.14em;
94
- text-transform: uppercase;
148
+ letter-spacing: 0.04em;
149
+ }
150
+
151
+ .openpress-workspace-gallery__filter-btn[data-active="true"] .openpress-workspace-gallery__filter-count {
152
+ color: var(--workspace-ink);
153
+ }
154
+
155
+ /* ── Main grid ───────────────────────────────────────────── */
156
+
157
+ .openpress-workspace-gallery__main {
158
+ min-width: 0;
95
159
  }
96
160
 
97
161
  .openpress-workspace-gallery__grid {
@@ -100,8 +164,6 @@
100
164
  padding: 0;
101
165
  display: grid;
102
166
  align-items: start;
103
- /* Uniform card size — Figma-style. Outer thumb is fixed 4:3, the
104
- inner page letterboxes to its own geometry. */
105
167
  grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr));
106
168
  gap: 1.5rem;
107
169
  }
@@ -110,6 +172,15 @@
110
172
  display: flex;
111
173
  }
112
174
 
175
+ .openpress-workspace-gallery__empty {
176
+ margin: 0;
177
+ padding: 3rem 0;
178
+ color: var(--workspace-muted);
179
+ font-size: 0.88rem;
180
+ }
181
+
182
+ /* ── Card ────────────────────────────────────────────────── */
183
+
113
184
  .openpress-workspace-gallery__card {
114
185
  appearance: none;
115
186
  display: grid;
@@ -138,13 +209,12 @@
138
209
  outline: none;
139
210
  }
140
211
 
212
+ /* ── Thumbnail ───────────────────────────────────────────── */
213
+
141
214
  .openpress-workspace-gallery__thumb {
142
215
  position: relative;
143
216
  display: block;
144
217
  width: 100%;
145
- /* Uniform 4:3 outer slot across every card. The page itself
146
- letterboxes inside via centered scale, so each Press shows at its
147
- own true aspect against the gradient background. */
148
218
  aspect-ratio: 4 / 3;
149
219
  background:
150
220
  linear-gradient(
@@ -220,7 +290,9 @@
220
290
  50% { opacity: 0.55; }
221
291
  }
222
292
 
223
- .openpress-workspace-gallery__body {
293
+ /* ── Card body ───────────────────────────────────────────── */
294
+
295
+ .openpress-workspace-gallery__card-body {
224
296
  display: grid;
225
297
  align-content: space-between;
226
298
  gap: 1.2rem;
@@ -262,10 +334,6 @@
262
334
  white-space: nowrap;
263
335
  }
264
336
 
265
- .openpress-workspace-gallery__geom {
266
- color: var(--workspace-card-muted);
267
- }
268
-
269
337
  .openpress-workspace-gallery__geom {
270
338
  display: inline-flex;
271
339
  align-items: center;
@@ -279,16 +347,31 @@
279
347
  white-space: nowrap;
280
348
  }
281
349
 
350
+ /* ── Responsive ──────────────────────────────────────────── */
351
+
352
+ @media (max-width: 860px) {
353
+ .openpress-workspace-gallery__body {
354
+ grid-template-columns: 1fr;
355
+ }
356
+
357
+ .openpress-workspace-gallery__sidebar {
358
+ position: static;
359
+ flex-direction: row;
360
+ flex-wrap: wrap;
361
+ gap: 6px;
362
+ }
363
+
364
+ .openpress-workspace-gallery__filter-btn {
365
+ width: auto;
366
+ flex: 0 0 auto;
367
+ }
368
+ }
369
+
282
370
  @media (max-width: 720px) {
283
371
  .openpress-workspace-gallery {
284
372
  padding: 2.25rem 1rem 4rem;
285
373
  }
286
374
 
287
- .openpress-workspace-gallery__header {
288
- display: grid;
289
- align-items: start;
290
- }
291
-
292
375
  .openpress-workspace-gallery__grid {
293
376
  grid-template-columns: 1fr;
294
377
  }