mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-22 02:23:46 +00:00
3bf1b60365
Adds a drag-to-crop modal on top of the AttachmentImage toolbar
introduced in TASK-879. The /transform endpoint already accepted
the "crop" operation shape from TASK-879 — this PR wires the editor
UI plus the supporting tests.
Editor:
- attachment-crop-modal.ts (new): pure-DOM crop modal in the same
style as the existing image lightbox. Returns a Promise that
resolves to the crop rect in ORIGINAL-IMAGE pixel coordinates
when the user clicks Apply, or null on cancel / dismiss /
image-load failure.
- Image fits to a centered <dialog> via flex layout; backdrop
click and Esc both cancel cleanly.
- Crop rectangle starts at 80% of the image, centered. Body is
a "move" handle; four corner handles resize.
- Aspect presets: Free, 1:1, 4:3, 16:9. Preset clicks snap the
current rect to the new ratio while preserving its center;
subsequent corner drags clamp to the locked ratio.
- Pointer events (touch + mouse for free) with setPointerCapture
so drag continues even if the cursor leaves the handle.
- Coordinate translation: rect in preview-pixel space →
naturalWidth / offsetWidth scale → original-image pixel
space. Result is clamped to natural bounds so a fractional-
rounding overrun doesn't push the rect off-image.
- attachment-image.ts: extracts swapNodeUuid() helper from
runRotate so runCrop can share the setNodeMarkup +
invalidate-old-metadata flow. The toolbar gains a fourth
button (⌶ Crop…) that opens the modal pointed at the original
variant. Per-format gating (refreshToolbarState) treats the
crop button identically to the rotate trio — both go through
/transform, so a libvips-only format (e.g. WebP on the pure-Go
build) disables the whole toolbar with the same explanatory
tooltip.
- app.css: full styling for the crop modal — header with aspect
toolbar, image stage with shadow-cutout overlay around the
crop rect, four corner handles, footer with Cancel + Apply.
Uses the existing CSS-variable palette so light/dark mode
track automatically.
Server tests (3 new):
- TestTransform_CropProducesNewBlobAtRectDimensions: end-to-end
PNG crop, verify the response dimensions AND that the served
bytes decode at the same dimensions (guards against an
encode-pipeline off-by-one).
- TestTransform_CropClipsToImageBounds: rect that extends past
the image boundary clips rather than 400ing — the editor's
rounding can produce rect+1px past natural width/height in
rare fractional-scale cases, and the processor's Crop
intersects with image bounds for exactly this reason.
- TestTransform_CropRejectsBadRect: missing rect, zero width,
negative xy, rect entirely outside → 400.
Parent: PLAN-866. Closes the editor-side image-tools track on top of
TASK-878 (Processor) and TASK-879 (rotate / transform endpoint).
959 lines
24 KiB
CSS
959 lines
24 KiB
CSS
:root {
|
||
--bg-primary: #1a1a1a;
|
||
--bg-secondary: #242424;
|
||
--bg-tertiary: #2e2e2e;
|
||
--bg-hover: #353535;
|
||
--bg-active: #3a3a3a;
|
||
|
||
--text-primary: #e0e0e0;
|
||
--text-secondary: #a0a0a0;
|
||
--text-muted: #666666;
|
||
|
||
--border: #333333;
|
||
--border-subtle: #2a2a2a;
|
||
|
||
--accent-blue: #4a9eff;
|
||
--accent-green: #4ade80;
|
||
--accent-purple: #a78bfa;
|
||
--accent-amber: #fbbf24;
|
||
--accent-cyan: #22d3ee;
|
||
--accent-orange: #fb923c;
|
||
--accent-gray: #9ca3af;
|
||
--accent-brown: #a8896c;
|
||
|
||
--status-draft: #666666;
|
||
--status-active: #4ade80;
|
||
--status-completed: #4a9eff;
|
||
--status-archived: #444444;
|
||
|
||
--topbar-height: 44px;
|
||
--sidebar-width: 260px;
|
||
--detail-panel-width: 300px;
|
||
--content-max-width: 960px;
|
||
|
||
--font-ui: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||
--font-content: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||
--font-mono: 'SF Mono', 'Fira Code', 'Consolas', monospace;
|
||
|
||
--space-1: 4px;
|
||
--space-2: 8px;
|
||
--space-3: 12px;
|
||
--space-4: 16px;
|
||
--space-5: 20px;
|
||
--space-6: 24px;
|
||
--space-8: 32px;
|
||
--space-10: 40px;
|
||
|
||
--radius: 6px;
|
||
--radius-sm: 4px;
|
||
--radius-lg: 8px;
|
||
}
|
||
|
||
* {
|
||
margin: 0;
|
||
padding: 0;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
html, body {
|
||
height: 100%;
|
||
font-family: var(--font-ui);
|
||
font-size: 14px;
|
||
line-height: 1.5;
|
||
color: var(--text-primary);
|
||
background: var(--bg-primary);
|
||
-webkit-font-smoothing: antialiased;
|
||
}
|
||
|
||
a {
|
||
color: var(--accent-blue);
|
||
text-decoration: none;
|
||
}
|
||
a:hover {
|
||
text-decoration: underline;
|
||
}
|
||
|
||
button {
|
||
font-family: inherit;
|
||
font-size: inherit;
|
||
cursor: pointer;
|
||
border: none;
|
||
background: none;
|
||
color: inherit;
|
||
}
|
||
|
||
input, textarea, select {
|
||
font-family: inherit;
|
||
font-size: inherit;
|
||
color: var(--text-primary);
|
||
background: var(--bg-tertiary);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius);
|
||
padding: var(--space-2) var(--space-3);
|
||
outline: none;
|
||
}
|
||
input:focus, textarea:focus {
|
||
border-color: var(--accent-blue);
|
||
}
|
||
|
||
/* Scrollbar */
|
||
::-webkit-scrollbar {
|
||
width: 6px;
|
||
}
|
||
::-webkit-scrollbar-track {
|
||
background: transparent;
|
||
}
|
||
::-webkit-scrollbar-thumb {
|
||
background: var(--border);
|
||
border-radius: 3px;
|
||
}
|
||
|
||
/* Prose styles for rendered markdown */
|
||
.prose {
|
||
font-family: var(--font-content);
|
||
font-size: 15px;
|
||
line-height: 1.7;
|
||
color: var(--text-primary);
|
||
max-width: var(--content-max-width);
|
||
}
|
||
.prose h1 { font-size: 1.8em; margin: 1.2em 0 0.6em; font-weight: 700; }
|
||
.prose h2 { font-size: 1.4em; margin: 1em 0 0.5em; font-weight: 600; }
|
||
.prose h3 { font-size: 1.15em; margin: 0.8em 0 0.4em; font-weight: 600; }
|
||
.prose p { margin: 0.6em 0; }
|
||
.prose ul, .prose ol { margin: 0.6em 0; padding-left: 1.5em; }
|
||
.prose li { margin: 0.2em 0; }
|
||
.prose li input[type="checkbox"] { margin-right: 0.4em; }
|
||
.prose pre {
|
||
background: var(--bg-tertiary);
|
||
padding: var(--space-4);
|
||
border-radius: var(--radius);
|
||
overflow-x: auto;
|
||
margin: 0.8em 0;
|
||
font-family: var(--font-mono);
|
||
font-size: 0.9em;
|
||
line-height: 1.5;
|
||
}
|
||
.prose code {
|
||
background: var(--bg-tertiary);
|
||
padding: 0.15em 0.4em;
|
||
border-radius: var(--radius-sm);
|
||
font-family: var(--font-mono);
|
||
font-size: 0.9em;
|
||
}
|
||
.prose pre code {
|
||
background: none;
|
||
padding: 0;
|
||
}
|
||
.prose blockquote {
|
||
border-left: 3px solid var(--border);
|
||
padding-left: var(--space-4);
|
||
margin: 0.8em 0;
|
||
color: var(--text-secondary);
|
||
}
|
||
.prose table {
|
||
border-collapse: collapse;
|
||
width: 100%;
|
||
margin: 0.8em 0;
|
||
}
|
||
.prose th, .prose td {
|
||
border: 1px solid var(--border);
|
||
padding: var(--space-2) var(--space-3);
|
||
text-align: left;
|
||
}
|
||
.prose th {
|
||
background: var(--bg-secondary);
|
||
font-weight: 600;
|
||
}
|
||
.prose hr {
|
||
border: none;
|
||
border-top: 1px solid var(--border);
|
||
margin: 1.5em 0;
|
||
}
|
||
.prose img {
|
||
max-width: 100%;
|
||
border-radius: var(--radius);
|
||
}
|
||
.prose a.doc-link {
|
||
background: var(--bg-tertiary);
|
||
padding: 0.1em 0.5em;
|
||
border-radius: var(--radius-sm);
|
||
color: var(--accent-blue);
|
||
font-weight: 500;
|
||
}
|
||
.prose a.doc-link:hover {
|
||
background: var(--bg-hover);
|
||
text-decoration: none;
|
||
}
|
||
.prose .doc-link.broken {
|
||
color: var(--accent-orange);
|
||
opacity: 0.7;
|
||
text-decoration: line-through;
|
||
}
|
||
|
||
/* External links in rendered markdown */
|
||
.prose .external-link {
|
||
color: var(--accent-blue);
|
||
text-decoration: none;
|
||
}
|
||
.prose .external-link:hover {
|
||
text-decoration: underline;
|
||
}
|
||
.prose .external-icon {
|
||
font-size: 0.75em;
|
||
opacity: 0.6;
|
||
vertical-align: super;
|
||
}
|
||
|
||
/* Editor link styles — autolinked URLs and wiki-links */
|
||
.prose a.editor-link {
|
||
color: var(--accent-blue);
|
||
text-decoration: none;
|
||
border-bottom: 1px solid color-mix(in srgb, var(--accent-blue) 30%, transparent);
|
||
transition: border-color 0.15s;
|
||
}
|
||
.prose a.editor-link:hover {
|
||
border-bottom-color: var(--accent-blue);
|
||
}
|
||
|
||
/* Attachment images — both inside the editor and in rendered markdown.
|
||
Lazy-loaded via the node's renderHTML/NodeView; styles below set a
|
||
sane default size so the editor doesn't reflow once thumbnails arrive. */
|
||
.attachment-image {
|
||
max-width: 100%;
|
||
height: auto;
|
||
border-radius: var(--radius);
|
||
cursor: zoom-in;
|
||
display: block;
|
||
margin: 0.4em 0;
|
||
}
|
||
.attachment-image:hover {
|
||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent-blue) 35%, transparent);
|
||
}
|
||
|
||
/* Wrapper appended by the AttachmentImage NodeView to host the
|
||
selection-time rotate toolbar (TASK-879). Position relative so
|
||
the toolbar can pin to the top-right corner of the image. */
|
||
.attachment-image-wrapper {
|
||
position: relative;
|
||
display: inline-block;
|
||
max-width: 100%;
|
||
}
|
||
.attachment-image-wrapper.attachment-image-selected .attachment-image {
|
||
box-shadow: 0 0 0 2px var(--accent-blue);
|
||
}
|
||
|
||
/* Rotate toolbar — appears when the AttachmentImage node is
|
||
selected. Pinned top-right with absolute positioning so it doesn't
|
||
shift the image's layout when toggling visibility. */
|
||
.attachment-image-toolbar {
|
||
position: absolute;
|
||
top: 8px;
|
||
right: 8px;
|
||
display: inline-flex;
|
||
gap: 2px;
|
||
padding: 4px;
|
||
background: rgba(20, 20, 20, 0.85);
|
||
border-radius: var(--radius);
|
||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
|
||
z-index: 5;
|
||
-webkit-user-select: none;
|
||
user-select: none;
|
||
}
|
||
.attachment-image-toolbar-hidden {
|
||
display: none;
|
||
}
|
||
.attachment-image-toolbar-btn {
|
||
width: 28px;
|
||
height: 28px;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 0;
|
||
border: none;
|
||
background: transparent;
|
||
color: #fff;
|
||
font-size: 18px;
|
||
line-height: 1;
|
||
border-radius: 4px;
|
||
cursor: pointer;
|
||
transition: background 0.15s, opacity 0.15s;
|
||
font-family: inherit;
|
||
}
|
||
.attachment-image-toolbar-btn:hover:not(:disabled) {
|
||
background: rgba(255, 255, 255, 0.15);
|
||
}
|
||
.attachment-image-toolbar-btn:disabled {
|
||
opacity: 0.4;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
/* Attachment file chips — Notion-style chip rendering for non-image
|
||
attachments. Used by both the editor's AttachmentChip node and the
|
||
read-only markdown render path (TASK-874). Styles target the same
|
||
class names from both surfaces so the visual matches edge to edge. */
|
||
.file-chip {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 0.4em;
|
||
max-width: 100%;
|
||
padding: 4px 10px;
|
||
margin: 0 1px;
|
||
background: var(--bg-tertiary);
|
||
border: 1px solid var(--border-subtle);
|
||
border-radius: 6px;
|
||
color: var(--text-primary);
|
||
text-decoration: none;
|
||
font-size: 0.92em;
|
||
line-height: 1.3;
|
||
vertical-align: baseline;
|
||
transition: background 0.15s, border-color 0.15s;
|
||
cursor: pointer;
|
||
}
|
||
.file-chip:hover {
|
||
background: var(--bg-hover);
|
||
border-color: color-mix(in srgb, var(--accent-blue) 35%, var(--border));
|
||
text-decoration: none;
|
||
}
|
||
.file-chip-icon {
|
||
flex-shrink: 0;
|
||
font-size: 1.05em;
|
||
line-height: 1;
|
||
}
|
||
.file-chip-name {
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
max-width: 38ch;
|
||
font-weight: 500;
|
||
}
|
||
.file-chip-size {
|
||
flex-shrink: 0;
|
||
color: var(--text-muted);
|
||
font-size: 0.88em;
|
||
}
|
||
.file-chip-size:empty {
|
||
display: none;
|
||
}
|
||
|
||
/* Editor selection styling — when the chip is the ProseMirror
|
||
selection (atom node selected for delete/replace), give it the same
|
||
selection treatment ProseMirror gives other inline nodes. */
|
||
.ProseMirror-selectednode.file-chip,
|
||
.file-chip.ProseMirror-selectednode {
|
||
outline: 2px solid var(--accent-blue);
|
||
outline-offset: 1px;
|
||
}
|
||
|
||
/* Upload-in-progress placeholder — rendered as a ProseMirror widget
|
||
decoration by the AttachmentUpload extension. Dashed border + faded
|
||
color so it visually reads as "not yet committed content" while the
|
||
network round-trip resolves. */
|
||
.attachment-upload-placeholder {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 0.4em;
|
||
padding: 4px 10px;
|
||
margin: 0 1px;
|
||
background: var(--bg-tertiary);
|
||
border: 1px dashed color-mix(in srgb, var(--accent-blue) 50%, var(--border));
|
||
border-radius: 6px;
|
||
color: var(--text-secondary);
|
||
font-size: 0.92em;
|
||
line-height: 1.3;
|
||
vertical-align: baseline;
|
||
}
|
||
.attachment-upload-spinner {
|
||
width: 12px;
|
||
height: 12px;
|
||
border: 2px solid color-mix(in srgb, var(--accent-blue) 35%, transparent);
|
||
border-top-color: var(--accent-blue);
|
||
border-radius: 50%;
|
||
animation: attachment-upload-spin 0.7s linear infinite;
|
||
flex-shrink: 0;
|
||
}
|
||
@keyframes attachment-upload-spin {
|
||
to {
|
||
transform: rotate(360deg);
|
||
}
|
||
}
|
||
.attachment-upload-label {
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
max-width: 30ch;
|
||
}
|
||
|
||
/* Crop modal (TASK-880). Appended to document.body so styles must
|
||
be global. Uses the native <dialog> element with a flex layout
|
||
so the image fills available space while header/footer stay fixed. */
|
||
dialog.attachment-crop-modal {
|
||
border: none;
|
||
background: transparent;
|
||
padding: 0;
|
||
max-width: 100vw;
|
||
max-height: 100vh;
|
||
color: #fff;
|
||
overflow: visible;
|
||
}
|
||
dialog.attachment-crop-modal::backdrop {
|
||
background: rgba(0, 0, 0, 0.85);
|
||
backdrop-filter: blur(2px);
|
||
}
|
||
.attachment-crop-modal-shell {
|
||
display: flex;
|
||
flex-direction: column;
|
||
width: min(95vw, 1200px);
|
||
height: min(92vh, 900px);
|
||
background: var(--bg-secondary);
|
||
color: var(--text-primary);
|
||
border-radius: var(--radius);
|
||
overflow: hidden;
|
||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.55);
|
||
}
|
||
.attachment-crop-modal-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: var(--space-3);
|
||
padding: var(--space-3) var(--space-4);
|
||
border-bottom: 1px solid var(--border);
|
||
background: var(--bg-secondary);
|
||
}
|
||
.attachment-crop-modal-title {
|
||
font-weight: 600;
|
||
font-size: 0.95em;
|
||
}
|
||
.attachment-crop-modal-aspects {
|
||
display: inline-flex;
|
||
gap: 4px;
|
||
background: var(--bg-tertiary);
|
||
padding: 2px;
|
||
border-radius: var(--radius);
|
||
}
|
||
.attachment-crop-modal-aspect-btn {
|
||
padding: 4px 12px;
|
||
border: none;
|
||
background: transparent;
|
||
color: var(--text-secondary);
|
||
border-radius: 4px;
|
||
font-size: 0.85em;
|
||
cursor: pointer;
|
||
font-family: inherit;
|
||
transition: background 0.15s, color 0.15s;
|
||
}
|
||
.attachment-crop-modal-aspect-btn:hover {
|
||
background: var(--bg-hover);
|
||
color: var(--text-primary);
|
||
}
|
||
.attachment-crop-modal-aspect-active,
|
||
.attachment-crop-modal-aspect-active:hover {
|
||
background: var(--accent-blue);
|
||
color: #fff;
|
||
}
|
||
.attachment-crop-modal-stage {
|
||
flex: 1;
|
||
min-height: 0;
|
||
position: relative;
|
||
overflow: hidden;
|
||
background: rgba(0, 0, 0, 0.4);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
-webkit-user-select: none;
|
||
user-select: none;
|
||
}
|
||
.attachment-crop-modal-img {
|
||
max-width: 100%;
|
||
max-height: 100%;
|
||
display: block;
|
||
-webkit-user-drag: none;
|
||
pointer-events: none;
|
||
}
|
||
.attachment-crop-modal-rect {
|
||
position: absolute;
|
||
box-sizing: border-box;
|
||
border: 1px solid #fff;
|
||
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.45);
|
||
cursor: move;
|
||
touch-action: none;
|
||
}
|
||
.attachment-crop-modal-handle {
|
||
position: absolute;
|
||
width: 12px;
|
||
height: 12px;
|
||
background: var(--accent-blue);
|
||
border: 2px solid #fff;
|
||
border-radius: 50%;
|
||
touch-action: none;
|
||
}
|
||
.attachment-crop-modal-handle-nw {
|
||
top: -7px;
|
||
left: -7px;
|
||
cursor: nwse-resize;
|
||
}
|
||
.attachment-crop-modal-handle-ne {
|
||
top: -7px;
|
||
right: -7px;
|
||
cursor: nesw-resize;
|
||
}
|
||
.attachment-crop-modal-handle-sw {
|
||
bottom: -7px;
|
||
left: -7px;
|
||
cursor: nesw-resize;
|
||
}
|
||
.attachment-crop-modal-handle-se {
|
||
bottom: -7px;
|
||
right: -7px;
|
||
cursor: nwse-resize;
|
||
}
|
||
.attachment-crop-modal-footer {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
gap: var(--space-2);
|
||
padding: var(--space-3) var(--space-4);
|
||
border-top: 1px solid var(--border);
|
||
background: var(--bg-secondary);
|
||
}
|
||
.attachment-crop-modal-cancel,
|
||
.attachment-crop-modal-apply {
|
||
padding: 6px 16px;
|
||
border: 1px solid var(--border);
|
||
background: var(--bg-tertiary);
|
||
color: var(--text-primary);
|
||
border-radius: var(--radius);
|
||
font-family: inherit;
|
||
font-size: 0.92em;
|
||
cursor: pointer;
|
||
transition: background 0.15s, border-color 0.15s;
|
||
}
|
||
.attachment-crop-modal-cancel:hover {
|
||
background: var(--bg-hover);
|
||
}
|
||
.attachment-crop-modal-primary {
|
||
background: var(--accent-blue);
|
||
color: #fff;
|
||
border-color: var(--accent-blue);
|
||
}
|
||
.attachment-crop-modal-primary:hover {
|
||
filter: brightness(1.1);
|
||
}
|
||
|
||
/* Lightbox dialog — appended to document.body when an attachment image
|
||
is clicked, so styles must be global rather than scoped to Editor.svelte.
|
||
Uses the native <dialog> element; the ::backdrop pseudo handles the
|
||
overlay shade. The dialog content fills the viewport with a centered
|
||
image and a single close button. */
|
||
dialog.attachment-image-lightbox {
|
||
border: none;
|
||
background: transparent;
|
||
padding: 0;
|
||
max-width: 100vw;
|
||
max-height: 100vh;
|
||
overflow: visible;
|
||
color: #fff;
|
||
}
|
||
dialog.attachment-image-lightbox::backdrop {
|
||
background: rgba(0, 0, 0, 0.85);
|
||
backdrop-filter: blur(2px);
|
||
}
|
||
dialog.attachment-image-lightbox .attachment-image-lightbox-img {
|
||
max-width: 95vw;
|
||
max-height: 95vh;
|
||
display: block;
|
||
margin: 0 auto;
|
||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.55);
|
||
cursor: zoom-out;
|
||
border-radius: var(--radius);
|
||
}
|
||
dialog.attachment-image-lightbox .attachment-image-lightbox-close {
|
||
position: fixed;
|
||
top: 16px;
|
||
right: 16px;
|
||
width: 40px;
|
||
height: 40px;
|
||
border-radius: 50%;
|
||
border: none;
|
||
background: rgba(0, 0, 0, 0.6);
|
||
color: #fff;
|
||
font-size: 28px;
|
||
line-height: 1;
|
||
cursor: pointer;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
z-index: 1;
|
||
}
|
||
dialog.attachment-image-lightbox .attachment-image-lightbox-close:hover {
|
||
background: rgba(0, 0, 0, 0.85);
|
||
}
|
||
|
||
/* Light mode */
|
||
[data-theme="light"] {
|
||
--bg-primary: #ffffff;
|
||
--bg-secondary: #f7f7f8;
|
||
--bg-tertiary: #eeeff1;
|
||
--bg-hover: #e5e6e8;
|
||
--bg-active: #dddee0;
|
||
--text-primary: #1a1a1a;
|
||
--text-secondary: #555555;
|
||
--text-muted: #999999;
|
||
--border: #e0e0e0;
|
||
--border-subtle: #eaeaea;
|
||
--accent-blue: #2563eb;
|
||
--accent-green: #16a34a;
|
||
--accent-purple: #7c3aed;
|
||
--accent-amber: #d97706;
|
||
--accent-cyan: #0891b2;
|
||
--accent-orange: #ea580c;
|
||
--accent-gray: #6b7280;
|
||
--accent-brown: #92400e;
|
||
--status-draft: #999999;
|
||
--status-active: #16a34a;
|
||
--status-completed: #2563eb;
|
||
--status-archived: #bbbbbb;
|
||
}
|
||
|
||
@media (prefers-color-scheme: light) {
|
||
:root:not([data-theme="dark"]) {
|
||
--bg-primary: #ffffff;
|
||
--bg-secondary: #f7f7f8;
|
||
--bg-tertiary: #eeeff1;
|
||
--bg-hover: #e5e6e8;
|
||
--bg-active: #dddee0;
|
||
--text-primary: #1a1a1a;
|
||
--text-secondary: #555555;
|
||
--text-muted: #999999;
|
||
--border: #e0e0e0;
|
||
--border-subtle: #eaeaea;
|
||
--accent-blue: #2563eb;
|
||
--accent-green: #16a34a;
|
||
--accent-purple: #7c3aed;
|
||
--accent-amber: #d97706;
|
||
--accent-cyan: #0891b2;
|
||
--accent-orange: #ea580c;
|
||
--accent-gray: #6b7280;
|
||
--accent-brown: #92400e;
|
||
--status-draft: #999999;
|
||
--status-active: #16a34a;
|
||
--status-completed: #2563eb;
|
||
--status-archived: #bbbbbb;
|
||
}
|
||
}
|
||
|
||
/* Responsive */
|
||
@media (max-width: 768px) {
|
||
:root {
|
||
--sidebar-width: 280px;
|
||
}
|
||
}
|
||
|
||
/* ============================================================================
|
||
Print styles (PLAN-620 / TASK-621).
|
||
Tune Ctrl/Cmd+P output so users can save any Pad page as a clean PDF.
|
||
This file handles LAYOUT-LEVEL concerns:
|
||
- hide the workspace chrome (sidebar, top bar, modals, floating UI)
|
||
- force a light color palette regardless of the active theme
|
||
- unlock the fixed 100vh / overflow:hidden app shell so content flows
|
||
across pages in the print preview
|
||
Item-level formatting (title, properties block, markdown body, header /
|
||
footer rules, child-item checklist) is layered on top in tasks 622–624.
|
||
============================================================================ */
|
||
|
||
@media print {
|
||
/* Force a light color palette for print regardless of the active theme.
|
||
We only need to pin the color variables; spacing / radii stay. */
|
||
:root,
|
||
:root[data-theme="dark"],
|
||
:root[data-theme="light"] {
|
||
--bg-primary: #ffffff;
|
||
--bg-secondary: #ffffff;
|
||
--bg-tertiary: #f5f5f5;
|
||
--bg-hover: transparent;
|
||
--bg-active: transparent;
|
||
--text-primary: #000000;
|
||
--text-secondary: #222222;
|
||
--text-muted: #555555;
|
||
--border: #cccccc;
|
||
--border-subtle: #dddddd;
|
||
}
|
||
|
||
html, body {
|
||
height: auto;
|
||
background: #ffffff !important;
|
||
color: #000000 !important;
|
||
font-size: 11pt;
|
||
}
|
||
|
||
/* Unlock the screen shell — it uses height: 100vh / overflow: hidden
|
||
to keep the sidebar + main column pinned. In print we need the
|
||
content to flow naturally across pages. */
|
||
.app-layout,
|
||
.app-shell {
|
||
display: block !important;
|
||
height: auto !important;
|
||
max-height: none !important;
|
||
overflow: visible !important;
|
||
}
|
||
.main-content {
|
||
flex: none !important;
|
||
width: 100% !important;
|
||
min-width: 0 !important;
|
||
overflow: visible !important;
|
||
}
|
||
|
||
/* Hide the workspace chrome. */
|
||
.sidebar,
|
||
.topbar,
|
||
.sidebar-expand-btn,
|
||
.topbar-expand-btn,
|
||
.mobile-header,
|
||
.hamburger,
|
||
.toast-container,
|
||
.overlay,
|
||
.palette,
|
||
.modal,
|
||
.modal-backdrop {
|
||
display: none !important;
|
||
}
|
||
|
||
/* Modals / dialogs / tooltips. Most KeyboardShortcuts-style overlays
|
||
carry role="dialog" on the backdrop. */
|
||
[role="dialog"],
|
||
[role="tooltip"],
|
||
[role="menu"] {
|
||
display: none !important;
|
||
}
|
||
|
||
/* Opt-in: any element tagged with data-print-hide disappears in print.
|
||
Components can use this to strip interactive affordances (edit buttons,
|
||
status pickers, hover cards, etc.) without needing a new CSS rule. */
|
||
[data-print-hide] {
|
||
display: none !important;
|
||
}
|
||
|
||
/* Strip decorative shadows, gradients, and background images. */
|
||
* {
|
||
box-shadow: none !important;
|
||
text-shadow: none !important;
|
||
background-image: none !important;
|
||
}
|
||
|
||
/* Page margins + page number (PLAN-620 / BUG-625).
|
||
- Top/bottom margins carve out space for the rendered header /
|
||
footer (TASK-623). Previously declared in +page.svelte, but
|
||
Svelte scoped-style timing meant the rule didn't always win
|
||
over this block, causing the header strip to overlap the
|
||
title on page 1. Single source of truth here.
|
||
- The page number lives in `@bottom-right` because
|
||
`counter(page)` evaluated inside a fixed element's ::after
|
||
pseudo captures its initial value (0) and never increments
|
||
per page in Chromium. Margin-box counters evaluate per page
|
||
correctly. */
|
||
@page {
|
||
/* Top margin is generous-but-plain (no reserved header strip):
|
||
the page-1 document header lives in normal flow via
|
||
`.title-row` + `.meta-info`, so subsequent pages start at
|
||
this margin with no overlap risk. Bottom margin reserves
|
||
space for the fixed footer + the `@bottom-right` page number.
|
||
See BUG-626 for why the repeating header was dropped. */
|
||
margin: 0.6in 0.6in 1in 0.6in;
|
||
@bottom-right {
|
||
content: "Page " counter(page);
|
||
font-family: var(--font-ui);
|
||
font-size: 9pt;
|
||
color: #555;
|
||
}
|
||
}
|
||
|
||
/* -----------------------------------------------------------------
|
||
TASK-622 — rendered markdown (.prose) tuned for print.
|
||
----------------------------------------------------------------- */
|
||
.prose {
|
||
font-size: 11pt;
|
||
line-height: 1.55;
|
||
color: #000 !important;
|
||
max-width: none;
|
||
}
|
||
.prose h1, .prose h2, .prose h3,
|
||
.prose h4, .prose h5, .prose h6 {
|
||
color: #000 !important;
|
||
page-break-after: avoid;
|
||
break-after: avoid-page;
|
||
page-break-inside: avoid;
|
||
break-inside: avoid-page;
|
||
}
|
||
.prose h1 { font-size: 18pt; }
|
||
.prose h2 { font-size: 14pt; }
|
||
.prose h3 { font-size: 12pt; }
|
||
|
||
.prose p, .prose li {
|
||
orphans: 3;
|
||
widows: 3;
|
||
}
|
||
|
||
/* Links: keep the colored style AND append the URL inline so a printed
|
||
page still carries the reference. Skip internal wiki-links (which
|
||
resolve to the printed document's siblings) and fragment-only links.
|
||
Note: rich-editor links are rendered via Tiptap's SafeLink extension
|
||
using `data-href` instead of `href`, so we target both. */
|
||
.prose a[href]::after {
|
||
content: " (" attr(href) ")";
|
||
font-size: 0.85em;
|
||
color: #555;
|
||
word-break: break-all;
|
||
}
|
||
.prose a[data-href]::after {
|
||
content: " (" attr(data-href) ")";
|
||
font-size: 0.85em;
|
||
color: #555;
|
||
word-break: break-all;
|
||
}
|
||
.prose a[href^="#"]::after,
|
||
.prose a[data-href^="#"]::after,
|
||
.prose a.doc-link::after,
|
||
.prose a.editor-link[href^="/"]::after,
|
||
.prose a.editor-link[data-href^="/"]::after {
|
||
content: "";
|
||
}
|
||
|
||
/* Code blocks: visible, readable in light palette, don't orphan across
|
||
pages when we can help it. */
|
||
.prose pre {
|
||
background: #f5f5f5 !important;
|
||
border: 1px solid #ddd !important;
|
||
color: #000 !important;
|
||
font-size: 9.5pt;
|
||
page-break-inside: avoid;
|
||
break-inside: avoid;
|
||
}
|
||
.prose code {
|
||
background: #f5f5f5 !important;
|
||
color: #000 !important;
|
||
}
|
||
|
||
/* Images: full page width max, don't split across pages. */
|
||
.prose img {
|
||
max-width: 100% !important;
|
||
page-break-inside: avoid;
|
||
break-inside: avoid;
|
||
}
|
||
|
||
/* Tables: visible borders, contained to page width. */
|
||
.prose table {
|
||
page-break-inside: avoid;
|
||
break-inside: avoid;
|
||
border: 1px solid #ccc !important;
|
||
}
|
||
.prose th, .prose td {
|
||
border: 1px solid #ccc !important;
|
||
color: #000 !important;
|
||
}
|
||
.prose th {
|
||
background: #f5f5f5 !important;
|
||
}
|
||
|
||
.prose blockquote {
|
||
border-left: 3px solid #666 !important;
|
||
color: #333 !important;
|
||
}
|
||
|
||
/* Editor overlays (bubble menu, link popover, slash menu, mobile
|
||
toolbar, table toolbar, editor-toolbar) — never print these. */
|
||
.bubble-menu,
|
||
.link-popover,
|
||
.slash-menu,
|
||
.mobile-toolbar,
|
||
.table-toolbar,
|
||
.toolbar,
|
||
.editor-toolbar,
|
||
.editor-link-popover,
|
||
.editor-bubble-menu {
|
||
display: none !important;
|
||
}
|
||
|
||
/* Editor wrapper: in print the content is read-only — remove any
|
||
interactive cursor, borders, or focus styling. */
|
||
.editor-wrapper,
|
||
.editor-content {
|
||
border: none !important;
|
||
background: transparent !important;
|
||
padding: 0 !important;
|
||
outline: none !important;
|
||
}
|
||
|
||
/* -----------------------------------------------------------------
|
||
TASK-622 — Properties panel form widgets in print.
|
||
These rules live here (not in the item detail page's scoped style
|
||
block) because the widgets are rendered by the FieldEditor child
|
||
component — Svelte's scoped selectors don't cross component
|
||
boundaries. Strip interactive styling so the selected value reads
|
||
as plain text; the `.toggle` checkbox button is exempted and
|
||
rendered as a visible outlined box with a check mark when on.
|
||
----------------------------------------------------------------- */
|
||
.field-value select,
|
||
.field-value input,
|
||
.field-value textarea,
|
||
.field-value button:not(.toggle) {
|
||
appearance: none !important;
|
||
-webkit-appearance: none !important;
|
||
background: transparent !important;
|
||
border: none !important;
|
||
padding: 0 !important;
|
||
margin: 0 !important;
|
||
color: #000 !important;
|
||
font: inherit !important;
|
||
cursor: default !important;
|
||
pointer-events: none;
|
||
box-shadow: none !important;
|
||
}
|
||
|
||
/* Custom select controls (FieldEditor) render a dropdown chevron as
|
||
an inline SVG (`.select-chevron`) inside the button — `appearance:
|
||
none` on the button doesn't affect SVG children, so the `∨` caret
|
||
leaks into print. Same story for the expanded dropdown panel
|
||
(`.select-dropdown`) if one happens to be open at print time.
|
||
BUG-625. */
|
||
.field-value .select-chevron,
|
||
.field-value .select-dropdown,
|
||
.select-chevron,
|
||
.select-dropdown {
|
||
display: none !important;
|
||
}
|
||
|
||
/* Checkbox field (`.toggle`): visible outlined box; on-state gets a
|
||
check mark overlay so the on/off signal survives in print. */
|
||
.field-value .toggle {
|
||
appearance: none !important;
|
||
-webkit-appearance: none !important;
|
||
display: inline-block !important;
|
||
width: 11pt !important;
|
||
height: 11pt !important;
|
||
padding: 0 !important;
|
||
margin: 0 !important;
|
||
background: #fff !important;
|
||
border: 1px solid #000 !important;
|
||
border-radius: 2pt !important;
|
||
position: relative;
|
||
cursor: default !important;
|
||
pointer-events: none;
|
||
box-shadow: none !important;
|
||
vertical-align: -1pt;
|
||
}
|
||
.field-value .toggle-knob {
|
||
display: none !important;
|
||
}
|
||
.field-value .toggle.on::after {
|
||
content: "✓";
|
||
position: absolute;
|
||
inset: 0;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: #000;
|
||
font-size: 10pt;
|
||
font-weight: 700;
|
||
line-height: 1;
|
||
}
|
||
}
|
||
|