Files
inkwell/frontend/src/style.css
T
bvandeusenandClaude Opus 5 ef418a8c92
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
buttons: one definition of the shape, worn by a <button> and by an <a>
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
2026-08-31 08:08:01 -04:00

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;
}
}