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 12s
CI & Build / integration (push) Successful in 20s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m54s
Android / Kotlin + Rust (APK) (push) Failing after 4m21s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m13s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Measured, the nine dark subdued fills were separated from each other by at most a 1.03 contrast ratio. That is not "subtle", it is identical, and it is why a board of them reads as one card repeated: "I only see 3 colors ... it looks like a monolithic wall." Two causes, and the second one is mine. NINE IS TOO FEW. The palette exists to say WHICH TAG. An untagged note's fill says nothing at all — it only has to keep the board from repeating. Those are different jobs and tying them together capped the second at nine values for a board that will hold hundreds. ONE AXIS IS TOO FEW. The subdued ramp varied hue while pinning every fill to the same lightness — deliberately, so each would read as a card against the board. But the eye separates by lightness first, so nine hues at one lightness are one card nine times. Hue alone was never going to carry it at that darkness. So an untagged note's fill is now generated from its id rather than looked up: hue anywhere on the circle, one of six lightness levels, saturation fixed. 324 distinct fills in dark and 193 in light, against nine. Separation between fills goes from a 1.03 ceiling to 1.42. Varying lightness is only SAFE because the card has its own grey edge now. While the fill was the card's only boundary it could not afford to drift toward the board; the edge bought that freedom, one commit before it was needed. Saturation is the one dial the hash never touches — variety comes from hue and lightness, loudness would come from saturation. Dark starts a hair under `neutral-900` and climbs, so no note is ever darker than a plain card. Light runs from white down past the board. Body text measures 8.7 at worst against the 4.5 it needs; the meta row 5.1 against 3.0. DOUBLE, NOT FLOAT, on the Kotlin side. JavaScript has one number type and it is binary64; a Kotlin Float is binary32, so the two would round differently near a channel boundary and a note would be one byte off between the phone and the browser. Nobody would ever file that — they would see two colours that are "sort of the same" and never work out why. The web half cannot be executed here at all (no node on this machine), so the Kotlin fixture test is the only place the two implementations are ever compared. It now pins eight generated values as well as the hash, plus the properties that actually matter: that lightness varies, that nothing sinks below the card surface, and that body text stays clear of AA. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
304 lines
11 KiB
CSS
304 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;
|
|
}
|
|
}
|
|
|
|
/* An untagged note's fill is GENERATED from its id, not chosen from the palette —
|
|
* see `derivedFill` in notes/colors.ts for why nine keys was never going to be
|
|
* enough. That means it cannot be a Tailwind class, and it cannot be a plain inline
|
|
* style either: light and dark are two different computed colours and an inline
|
|
* style has no way to answer a media query. So the card publishes both as custom
|
|
* properties and this rule picks between them.
|
|
*
|
|
* The fallbacks are the draft case. A note with no id yet has nothing to hash, so it
|
|
* publishes no properties and lands on the plain card surface — one colour change at
|
|
* save time, rather than every draft sharing a fill and then changing anyway. */
|
|
.note-tint {
|
|
background-color: var(--tint-light, #ffffff);
|
|
}
|
|
|
|
@media (prefers-color-scheme: dark) {
|
|
.note-tint {
|
|
background-color: var(--tint-dark, #171717);
|
|
}
|
|
}
|
|
|
|
/* 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;
|
|
}
|
|
}
|
|
|
|
/* The per-card colour popover, anchored to whichever end of the card the action set
|
|
* currently occupies: it opens DOWNWARD from a floating top-corner pill, and UPWARD
|
|
* from a footer row, so in both cases it grows into the card rather than off it. */
|
|
.note-swatches {
|
|
position: absolute;
|
|
right: 0;
|
|
bottom: 100%;
|
|
margin-bottom: 0.375rem;
|
|
z-index: 20;
|
|
}
|
|
@media (hover: hover) {
|
|
.note-swatches {
|
|
top: 100%;
|
|
bottom: auto;
|
|
margin-top: 0.375rem;
|
|
margin-bottom: 0;
|
|
}
|
|
}
|
|
|
|
/* 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;
|
|
}
|
|
}
|