Android / Build, or is the channel already serving this? (push) Successful in 4s
CI & Build / Build now, or wait for Android? (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Skipped
CI & Build / Python lint (push) Successful in 4s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 12s
CI & Build / integration (push) Successful in 18s
CI & Build / Build & push image (push) Successful in 40s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m1s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m14s
Desktop (Tauri) / Update manifest (push) Successful in 6s
The download links added in fd1e4ae carried their own copy of BaseButton's
class list, because BaseButton is a <button> and cannot hold an href — and a
download must be an anchor, so the browser's own download manager gets the
3-95 MB transfer instead of a blob this app would have to hold in memory.
A copy is not a solution to that; it is two primary buttons that look alike
until someone changes one. So the shape moves to `.btn` + `.btn-primary` /
`.btn-ghost` in the components layer, where both elements can wear it, and
neither owns it.
The `disabled:` variants stay on BaseButton. An anchor has no :disabled, so
they were never shared and pretending otherwise would put a rule in the
shared definition that only one of its two users can ever match.
Verified there is exactly one shape to unify and no third copy: `px-4 py-2.5`
appears in three other files and all three are something else (a toast, a
dashed quick-add affordance, a retention notice). The smaller brand buttons in
AppShell and NoteEditor are a different size, which is a size-variant question
and not this one. And exactly one call site passes a class to BaseButton —
`shrink-0` — which cannot conflict with anything the shape declares.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3MMqUtzX1TJgA1oypvm1c
294 lines
11 KiB
CSS
294 lines
11 KiB
CSS
@tailwind base;
|
|
@tailwind components;
|
|
@tailwind utilities;
|
|
|
|
html,
|
|
body,
|
|
#app {
|
|
height: 100%;
|
|
}
|
|
|
|
/* Track the VISUAL viewport, not the layout viewport.
|
|
*
|
|
* On a phone the two diverge the moment the on-screen keyboard opens or the URL bar
|
|
* retracts: `100%` keeps the taller layout viewport, which slides the sticky header
|
|
* off screen and can park a focused field underneath the keyboard. `dvh` is the unit
|
|
* that follows the box actually being shown.
|
|
*
|
|
* Same three selectors rather than a fourth rule, so the box model is unchanged and
|
|
* only the number moves; behind @supports so a browser without `dvh` keeps the
|
|
* `100%` above instead of falling back to nothing. */
|
|
@supports (height: 100dvh) {
|
|
html,
|
|
body,
|
|
#app {
|
|
height: 100dvh;
|
|
}
|
|
}
|
|
|
|
/* Landscape on a notched phone puts the cutout down one SIDE of the page, and with
|
|
* `viewport-fit=cover` (index.html) the page is drawing under it. Applied to body so
|
|
* every view inherits it, rather than each container remembering; the insets are 0
|
|
* on every device that has no cutout, and 0 in portrait, so this costs nothing where
|
|
* it isn't needed. Top and bottom are NOT done here — a sticky header and a scrolling
|
|
* board need them in their own boxes, not on the page. */
|
|
body {
|
|
padding-left: env(safe-area-inset-left, 0px);
|
|
padding-right: env(safe-area-inset-right, 0px);
|
|
}
|
|
|
|
body {
|
|
@apply bg-neutral-50 text-neutral-900 antialiased;
|
|
}
|
|
|
|
@media (prefers-color-scheme: dark) {
|
|
body {
|
|
@apply bg-neutral-950 text-neutral-100;
|
|
}
|
|
}
|
|
|
|
/* Motion is a feature, not a given. Anyone whose OS says "reduce motion" has told
|
|
* us something about vestibular comfort or attention, and the answer is to arrive
|
|
* instantly rather than to animate faster.
|
|
*
|
|
* Global and blunt on purpose: it catches every Tailwind `transition` utility
|
|
* already scattered through the components, and it will catch the M7 motion work
|
|
* without each new component having to remember. Near-zero rather than `none` so
|
|
* `transitionend`/`animationend` listeners still fire and no state machine that
|
|
* waits on them can hang. JS-driven motion can't be reached from CSS — that reads
|
|
* the same preference through `useReducedMotion`. */
|
|
@media (prefers-reduced-motion: reduce) {
|
|
*,
|
|
*::before,
|
|
*::after {
|
|
animation-duration: 0.01ms !important;
|
|
animation-iteration-count: 1 !important;
|
|
transition-duration: 0.01ms !important;
|
|
scroll-behavior: auto !important;
|
|
}
|
|
}
|
|
|
|
/* Controls that a card or row reveals on hover — the note toolbar, the drag grip,
|
|
* per-item delete buttons. Marked with this class rather than expressed inline,
|
|
* because the rule below is the whole reason they need naming.
|
|
*
|
|
* A finger cannot hover. On a device with no hover at all these are not "harder to
|
|
* reach", they are unreachable — and for a note card the hover toolbar is the ONLY
|
|
* way to pin, colour or archive. So where hovering is impossible they are simply
|
|
* always present. `@media (hover: none)` asks the device directly, which is more
|
|
* honest than inferring it from viewport width: a small window on a laptop still
|
|
* hovers, and a large tablet still doesn't.
|
|
*
|
|
* Placed after the Tailwind directives so it wins on source order against the
|
|
* `opacity-0` / `pointer-events-none` utilities it overrides, without !important.
|
|
* The `group-hover:` reveal stays in the markup — the trigger differs per component
|
|
* (named groups), only this fallback is shared. */
|
|
@media (hover: none) {
|
|
.hover-reveal {
|
|
opacity: 1;
|
|
pointer-events: auto;
|
|
}
|
|
}
|
|
|
|
/* A note card's own controls — the drag grip and the pin/colour/archive/trash set.
|
|
* ONE element each, in TWO placements, because "always visible" and "floating over
|
|
* the card's top corners" cannot both be true without the controls sitting on top of
|
|
* the note's own words.
|
|
*
|
|
* That is exactly what a phone got. `.hover-reveal` above made the pills permanent
|
|
* where nothing can hover (task 2697, correctly — they are the only way to pin or
|
|
* archive), but they were still absolutely positioned, so they covered the title:
|
|
* "thought sync tauri app" rendered as "ught sync tauri app" with the grip parked on
|
|
* the first three characters.
|
|
*
|
|
* So the default — no hover — is a footer row IN FLOW, which cannot overlap anything
|
|
* by construction. A pointer that can hover lifts them back out into the floating
|
|
* pills they have always been, where they cost no vertical space and appear only on
|
|
* approach. `display: contents` drops the flex row itself out of the way so each
|
|
* child positions against the card.
|
|
*
|
|
* Keyed on hover rather than width, for the reason `.hover-reveal` already gives: a
|
|
* narrow window on a laptop still hovers, and a wide tablet still doesn't. */
|
|
.note-actions {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: flex-end;
|
|
gap: 0.125rem;
|
|
margin-top: 0.5rem;
|
|
}
|
|
.note-grip {
|
|
/* Pushes the action set to the far edge, so the two read as opposite ends of a
|
|
* footer rather than as a clump. */
|
|
margin-right: auto;
|
|
}
|
|
.note-actions-set {
|
|
position: relative; /* the colour popover anchors here in both placements */
|
|
}
|
|
|
|
@media (hover: hover) {
|
|
.note-actions {
|
|
display: contents;
|
|
}
|
|
.note-grip {
|
|
position: absolute;
|
|
left: 0.375rem;
|
|
top: 0.375rem;
|
|
z-index: 10;
|
|
margin-right: 0;
|
|
}
|
|
.note-actions-set {
|
|
position: absolute;
|
|
right: 0.375rem;
|
|
top: 0.375rem;
|
|
}
|
|
}
|
|
|
|
/* Board motion (M7). Defined once here rather than three times in BoardView's
|
|
* markup, because "how the board moves" is one idea even though the pinned, other
|
|
* and non-board grids are three TransitionGroups.
|
|
*
|
|
* Vue's TransitionGroup does the FLIP itself: it measures each card before and
|
|
* after the list changes and transitions the difference away. All we supply is the
|
|
* curve. Deliberately fast and small — the brief is continuity, so a card should
|
|
* read as having MOVED rather than as having performed.
|
|
*
|
|
* Leavers are NOT taken out of flow with `position: absolute`, which is the usual
|
|
* TransitionGroup trick: this masonry is CSS multi-column, and an absolutely
|
|
* positioned child escapes its column to the container's origin — it would fly
|
|
* across the board on its way out. Keeping them in flow costs a small settle when
|
|
* the element is finally removed, so the leave is the shortest of the three.
|
|
*
|
|
* Reduced motion needs no special case here: the global guard above already
|
|
* collapses every duration, so this degrades to an instant cut. */
|
|
.board-move {
|
|
transition: transform 220ms cubic-bezier(0.2, 0, 0, 1);
|
|
}
|
|
.board-enter-active {
|
|
transition:
|
|
opacity 180ms ease-out,
|
|
transform 180ms cubic-bezier(0.2, 0, 0, 1);
|
|
}
|
|
.board-leave-active {
|
|
transition: opacity 120ms ease-in;
|
|
}
|
|
.board-enter-from {
|
|
opacity: 0;
|
|
/* Barely a scale — enough to read as arriving, not as zooming. */
|
|
transform: scale(0.98);
|
|
}
|
|
.board-leave-to {
|
|
opacity: 0;
|
|
}
|
|
|
|
/* Editor open/close (M7). The backdrop only fades; the panel scales from the point
|
|
* the card occupied, which is what carries "this card became the editor" without
|
|
* the distortion a true card→panel scale would put through the text.
|
|
*
|
|
* Enter is slower than leave on purpose: arriving wants to be noticed as a
|
|
* connection, leaving just wants to be out of the way. LEAVE_MS in NoteEditor.vue
|
|
* must stay in step with the leave duration here — it waits that long before
|
|
* telling its host to unmount it. */
|
|
.editor-enter-active {
|
|
transition: opacity 200ms ease-out;
|
|
}
|
|
.editor-leave-active {
|
|
transition: opacity 140ms ease-in;
|
|
}
|
|
.editor-enter-from,
|
|
.editor-leave-to {
|
|
opacity: 0;
|
|
}
|
|
.editor-enter-active .editor-panel {
|
|
transition: transform 200ms cubic-bezier(0.2, 0, 0, 1);
|
|
}
|
|
.editor-leave-active .editor-panel {
|
|
transition: transform 140ms ease-in;
|
|
}
|
|
.editor-enter-from .editor-panel,
|
|
.editor-leave-to .editor-panel {
|
|
transform: scale(0.94);
|
|
}
|
|
|
|
/* Lens cross-fade (M7). Only fires between genuinely different surfaces; the
|
|
* board's own lenses share a component and reflow in place instead. Out is quicker
|
|
* than in so the gap between them stays short — `mode="out-in"` means the two
|
|
* durations are additive, and anything slower starts to feel like waiting. */
|
|
.lens-enter-active {
|
|
transition: opacity 150ms ease-out;
|
|
}
|
|
.lens-leave-active {
|
|
transition: opacity 100ms ease-in;
|
|
}
|
|
.lens-enter-from,
|
|
.lens-leave-to {
|
|
opacity: 0;
|
|
}
|
|
|
|
@layer components {
|
|
.icon-btn {
|
|
@apply rounded-md p-1.5 text-neutral-500 transition hover:bg-black/5 focus:outline-none
|
|
focus-visible:ring-2 focus-visible:ring-brand dark:text-neutral-400 dark:hover:bg-white/10;
|
|
}
|
|
/* Finger-sized where a finger is the input. p-1.5 around an 18px icon lands near
|
|
* 30px — comfortable for a cursor, too small for a thumb (Android asks 48dp,
|
|
* WCAG 2.2 AAA asks 44px). Scoped to coarse pointers so desktop chrome doesn't
|
|
* inflate; inline-flex because a bare min-height would leave the icon off-centre. */
|
|
@media (pointer: coarse) {
|
|
.icon-btn {
|
|
@apply inline-flex min-h-[2.75rem] min-w-[2.75rem] items-center justify-center;
|
|
}
|
|
}
|
|
/* A small action sitting beside a chip on a card — reminder Done/snooze today.
|
|
* Quiet at rest so a board full of reminders doesn't read as a wall of buttons,
|
|
* and it earns contrast on hover/focus. */
|
|
.chip-btn {
|
|
@apply rounded-full px-2 py-0.5 text-xs text-neutral-500 transition hover:bg-black/5
|
|
hover:text-neutral-800 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand
|
|
dark:text-neutral-400 dark:hover:bg-white/10 dark:hover:text-neutral-100;
|
|
}
|
|
/* Same finger rule as .icon-btn: comfortable for a cursor is too small for a
|
|
* thumb, and these are the primary action on a due note. */
|
|
@media (pointer: coarse) {
|
|
.chip-btn {
|
|
@apply inline-flex min-h-[2.25rem] items-center justify-center px-3;
|
|
}
|
|
}
|
|
/* THE button shape — the ONE definition of it in the app.
|
|
*
|
|
* It lives here, in the components layer, rather than inside BaseButton.vue,
|
|
* because not every button in this app is a <button>. A DOWNLOAD has to be an
|
|
* anchor: these are 3-95 MB installers, only an <a> can carry an href, and the
|
|
* browser's own download manager handles that transfer better than anything the
|
|
* app would do by fetching to a blob. BaseButton cannot serve that case, and a
|
|
* second copy of its class list for anchors is how a page ends up with two
|
|
* kinds of primary button that drift apart.
|
|
*
|
|
* So: BaseButton.vue wears these, and so does any anchor that must read as a
|
|
* button. Neither owns the look.
|
|
*
|
|
* The `disabled:` variants are NOT here on purpose — an anchor has no
|
|
* :disabled. BaseButton adds them itself, which is exactly the split: shared
|
|
* where it is shared, local where the element differs.
|
|
*/
|
|
.btn {
|
|
@apply inline-flex items-center justify-center gap-2 rounded-lg px-4 py-2.5 text-sm
|
|
font-semibold transition focus:outline-none focus-visible:ring-2 focus-visible:ring-brand
|
|
focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-50
|
|
dark:focus-visible:ring-offset-neutral-950;
|
|
}
|
|
.btn-primary {
|
|
@apply bg-brand text-neutral-900 shadow-sm hover:bg-brand-600 active:bg-brand-700;
|
|
}
|
|
.btn-ghost {
|
|
@apply text-neutral-700 hover:bg-neutral-200/70 dark:text-neutral-200 dark:hover:bg-neutral-800;
|
|
}
|
|
.nav-link {
|
|
@apply flex items-center gap-2 rounded-lg px-3 py-2 font-medium text-neutral-600 transition
|
|
hover:bg-neutral-200/60 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand
|
|
dark:text-neutral-300 dark:hover:bg-neutral-800;
|
|
}
|
|
.nav-link-active {
|
|
@apply bg-neutral-200 text-neutral-900 dark:bg-neutral-800 dark:text-neutral-100;
|
|
}
|
|
}
|