CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 14s
CI & Build / integration (push) Successful in 19s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 2m28s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m52s
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Failing after 4m1s
Step 3 of M315, and the destructive half. Steps 1 and 2 stopped every read of this field: a card is one neutral surface per theme, and the only coloured thing on a board is a tag. What was left was a column written by a picker and read by nothing. Rule 22 — the old path comes out completely. No flag, no fallback, no "override if set". Server: the column, the `?color=` facet, the create/update/serialise paths, the sync assignment, the front-matter line, and Keep's colour map. Alembic 0029 drops it and sweeps `"color"` out of stored saved-filter params — a view that silently filtered on a field the app no longer has would return nothing and never say why. That sweep is Python, not `params::jsonb - 'color'`, because Postgres has no try-cast and one malformed blob would abort a migration that is running over somebody's saved views. `NOTE_COLORS` moves from `models/note.py` to `colors.py`. A palette defined on the model that lost one is an invitation to put the column back; labels still name a colour, so the vocabulary belongs where the normalizer already is. Core: the field, the facet, the `NoteCreateInput`, and every read and write in store/push/pull. Local schema v9 drops the column and does the same saved-filter sweep, guarded on `json_valid` so a corrupt blob loses a key rather than becoming NULL. The uniffi layer drops `NoteEdit::Color` and `NoteDraft.color` with it. Web: `ColorPicker.vue`, the per-card swatch popover and its stylesheet rule, the FilterBar colour row, the facet in the query round-trip, and the colour half of the editor's baseline-and-save. Android: the `ColorSheet`, the `Picker.COLOR` case, the toolbar's swatch dot, `EditorAction.SetColor`. ## The protocol: v4, and the floor deliberately stays at 3 Checked against `compat.rs` and the push handler rather than trusting the `#[serde(default)]` annotation, because the v2 precedent points the other way: v2 dropped `kind` and `title` and DID raise both floors, on the rule that dropping a field a client sends and expects back is breaking. `color` fails the second half of that test. A v3 client reading a v4 note gets `"default"` from its own serde default and draws the colour it derives locally — the board it drew yesterday. A v3 client pushing `color` has the key ignored, since `_assign_note_fields` reads its payload key by key and never validates the shape. Neither direction errors and neither shows anything wrong. `title` was the note's NAME; this is a field that no longer renders. So `SYNC_PROTOCOL_VERSION` and `CLIENT_PROTOCOL_VERSION` go to 4, and both floors stay at 3. `docs/sync.md` carries the reasoning and the per-version history, and its push example is brought back in line — it still listed `title`, `kind` and `items`, all gone before this. Import stays tolerant: a pre-M315 export or a Keep takeout carrying `color:` imports fine, the key simply read past. Old exports must still import. #3041 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
265 lines
10 KiB
CSS
265 lines
10 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;
|
|
}
|
|
}
|
|
.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;
|
|
}
|
|
}
|