softr-vibe-coding 2.4.2 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,9 @@ All notable changes to this skill are documented here. Versions follow [Semantic
4
4
 
5
5
  Entries from 1.3.1 onward are generated automatically from git commit subjects between version bumps (see `.github/workflows/publish.yml`). Entries before 1.3.1 were backfilled by hand from the existing commit history.
6
6
 
7
+ ## [2.5.0] - 2026-09-09
8
+ - Cross-block and cross-page consistency: the failure mode where every block looks correct alone
9
+
7
10
  ## [2.4.2] - 2026-09-01
8
11
  - softr-mcp.md: Workflows build/publish loop verified end to end 2026-09-01 (10 production workflows built live via MCP), replacing the not-yet-exercised hedge with build-loop findings: create_workflow instantiates an OLD trigger node version — immediately replace_trigger_node with the same type for current inputs (updateField on SOFTR_TABLES_RECORD_UPDATED only exists at v1.2.0); FILTER conditions set via update_node_inputs inputName "condition" ({operator, conditions} object stored on the outgoing path like the builder); LOOP_ACTION_GROUP loopVariables.items must be a plain array like $.records — [*] projections rejected by the validator, per-item refs inside the loop via {loopActionGroup.<id>:::loopVariables.items.fields.<fieldId>} (bracket form for digit-leading ids); update_node_inputs batches validate against STORED node state so dependent updates split into sequential calls; test-safety doctrine — record-write nodes REAL_ONLY (never test against production), SOFTR_SEND_EMAIL always mode:"mock", triggers/GET_RECORDS REAL_ONLY but read-only-safe. Plus: workspace update_field on SELECT silently drops added choices (echoes old set, no error) — add choices in Studio or let allowToAddNewChoice auto-create on first unknown-label write; joins the allowMultipleEntries silent-ignore
9
12
 
package/README.md CHANGED
@@ -291,7 +291,8 @@ The skill enforces these automatically, but good to know (verified live against
291
291
  - Create payloads are **flat**; update payloads are `{ recordId, fields: {...} }` — asymmetric by design
292
292
  - `mutateAsync` is fully supported — it's the tool for sequential multi-row saves
293
293
  - SELECT fields write by option **label string**; linked records write as arrays of record-id strings
294
- - Every code recompile resets the block's auto-registered Actions to default permissions — tighten permissions after the last redeploy
294
+ - Every code recompile resets the block's auto-registered Actions to default permissions — tighten permissions after the last redeploy, and note there is **no cosmetic-edit exemption**: an edit that changes only a comment resets them too
295
+ - **Blocks cannot import each other**, so two blocks that must look alike will drift — each one looks correct in isolation while the set does not. Repeated page chrome (back button, title, primary action) must sit at the same offset on every page, and a loading skeleton must track the REST state of whatever it stands in for
295
296
  - No `import React from 'react'` — use named imports (`import { useState } from "react"`)
296
297
  - Must use `export default function Block()`
297
298
  - Wrap layout in `<div className="container py-0"><div className="content">` for app/content blocks (house convention for width alignment with native blocks) — the platform default is actually full width, so full-bleed marketing blocks (heroes, banners, footers) legitimately omit the wrappers and own their gutters
package/SKILL.md CHANGED
@@ -222,6 +222,25 @@ Blocks rarely live alone — most Softr pages stack 2–4 blocks vertically, oft
222
222
  1. **"Should the detail page have a back button?"** — if yes, always wire one. Use the back-navigation pattern in [references/helper-blocks.md](references/helper-blocks.md#breadcrumb--back-navigation): `window.history.back()` for users with history, plus a fallback URL for users who arrived via shared link.
223
223
  2. **"What page should the back button fall back to when there's no history?"** — this is a separate question, easy to skip but important. Don't default silently; ask. If the user doesn't have a listing page yet, default to `/` and leave a `// TODO: update fallback when /jobs (or similar) exists` comment so it can be updated later.
224
224
 
225
+ **Chrome that repeats across pages must land in the SAME place on every page [house].** A back button,
226
+ a page title, a primary action -- anything the user meets on more than one screen -- is a cross-page
227
+ contract, not a per-block decision. Before adding one, open the blocks that already have it and copy
228
+ the exact offset; when you change it, change it everywhere in the same edit. Verified the hard way
229
+ 2026-09-09: an item-detail back button carried an extra `mt-6` that the project-header back button did
230
+ not, so it sat 24px lower, and the mismatch only surfaced when a user moved between the two pages in
231
+ one session. **No amount of reading a single block reveals this** -- each block looks correct alone,
232
+ which is exactly why it needs to be a standing rule rather than a review item.
233
+
234
+ Two habits make it survive:
235
+
236
+ - **Let the wrapper's padding be the ONLY thing positioning repeated chrome.** Give the back button
237
+ `mb-4` (space below it) and no top margin, so its offset is the wrapper's top padding and nothing
238
+ else. One number per page then governs the position, and pages can only drift if their wrappers do.
239
+ - **Loading skeletons repeat the chrome too.** A skeleton standing in for a page that has a back button
240
+ needs that button's placeholder at the same offset as the real one, or the button visibly jumps the
241
+ moment the record arrives. Change both in the same edit -- see §12 of
242
+ [ui-ux-guidelines.md](ui-ux-guidelines.md).
243
+
225
244
  **Persist the answer as a grep-able comment at the top of the generated file** so future edits know the spacing assumptions and can be updated consistently:
226
245
 
227
246
  ```jsx
@@ -323,6 +342,16 @@ Adjust the top gradient color to complement the user's brand.
323
342
 
324
343
  **Arbitrary values compile in full** — the platform's Tailwind build is JIT, so the whole arbitrary-value syntax works, including opacity modifiers on arbitrary hex (`bg-[#FAF5EC]/85`), variant + arbitrary + opacity combined (`hover:bg-[#6E7A5C]/10`), negative arbitrary values (`-top-[22%]`, `hover:-translate-y-[1px]`), arbitrary object-position (`object-[62%_25%]`), arbitrary z (`z-[1]`), and vw sizing (verified 2026-08-31 from rendering Studio-AI output). Classes must be **static source strings** — never template-interpolate (`` bg-[${x}] ``); JIT extracts classes by static scan (standard-Tailwind inference, not Softr-verified). When to reach for them vs. the scale: see the editorial lane in [ui-ux-guidelines.md](ui-ux-guidelines.md) §7. (This covers arbitrary *values* and standard variants; arbitrary *selector* variants like `[&_svg]:` have at least one known bundler failure — see the SelectTrigger row in [references/anti-patterns.md](references/anti-patterns.md#layout--styling).)
325
344
 
345
+ **A brand colour you use BOTH ways exists twice, and the two copies drift silently.** Arbitrary values
346
+ are resolved at build time, so a class string can never read your `C.accent` constant. A card styled
347
+ with Tailwind (`border-[#3B1F2B] hover:border-[#54594F]`) and its loading skeleton styled inline
348
+ (`style={{ border: "1px solid " + C.accent }}`) therefore hold the same colour in two places that no
349
+ compiler will ever reconcile, and nothing fails when they disagree -- it just looks wrong. Verified
350
+ 2026-09-09: a card's rest border was changed and its skeleton's was not, so the whole grid visibly
351
+ re-outlined itself the instant the data arrived. Two habits keep it honest: write the hex-to-token
352
+ mapping in a comment beside the class string (`#3B1F2B = C.accent`), and prefer the runtime token
353
+ wherever inline `style` is already in play, so only one of the two copies is ever a literal.
354
+
326
355
  **Font classes:** `font-heading`, `font-sans`, `font-mono`
327
356
 
328
357
  **Conditional classNames:** `import { cn } from "@/lib/utils";` — template-literal conditionals (`` className={`base ${cond ? "a" : "b"}`} ``) are equally valid (Studio AI emits them); prefer `cn()` when merging many groups or de-duplicating conflicting classes.
@@ -517,6 +546,21 @@ Non-negotiable rules. Most are enforced by the Softr platform (compiler, validat
517
546
  auto-registered Actions to **default permissions**. Do the Actions-tab permission tightening pass
518
547
  only after the LAST redeploy, and re-check it after any future one. Verified live 2026-08-25
519
548
  across a 15-block deployment. See [datasources/writing.md](datasources/writing.md#how-actions-work-studios-actions-tab).
549
+ **Any save counts, including one whose only change is a comment** -- there is no "cosmetic edit"
550
+ exemption; a `search_replace` that rewrites nothing but a code comment rebuilds the Actions exactly
551
+ like a full rewrite does (verified live 2026-09-09, on two blocks at once). Re-check permissions
552
+ after EVERY push.
553
+ 22. **Blocks cannot import each other -- cross-block consistency is discipline, not architecture [house]**
554
+ -- Every block compiles standalone. There is no shared module, no design-system import, nothing that
555
+ makes two blocks stay alike. Two blocks that must look the same WILL drift, and the drift usually
556
+ hides in the *mechanism* rather than the value: verified 2026-09-09, a report card and a saved-list
557
+ card that were supposed to be identical had ended up hovering by two different mechanisms (Tailwind
558
+ `hover:` variants on one, React `useState` on the other), so a change to either could never reach
559
+ the other. Neither block looked wrong on its own -- that is the whole problem. When a treatment is
560
+ deliberately shared across blocks: name it in a comment in BOTH files, list the exact tokens that
561
+ are shared, and promise nothing beyond them (the class strings usually differ in layout and padding,
562
+ and do not need to match). The same rule governs repeated page chrome -- see **Block Placement &
563
+ Page Spacing**.
520
564
 
521
565
  ## Style Conventions
522
566
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "softr-vibe-coding",
3
- "version": "2.4.2",
3
+ "version": "2.5.0",
4
4
  "description": "Claude Code skill for generating production-ready Softr Vibe Coding blocks (JSX). Installs into ~/.claude/skills/ and auto-updates on each Claude Code session.",
5
5
  "bin": {
6
6
  "softr-vibe-coding": "./bin/cli.js"
@@ -38,6 +38,7 @@ Run through this catalog before delivering any block. Every row is a violation o
38
38
  | Wrapping a `useRecordCreate` payload in `{ fields: {...} }` (copied from an update call) | Create payloads are **FLAT** — `createRecord.mutate({ name: "Jane" })`. Only update payloads nest: `{ recordId, fields: {...} }`. The asymmetry is by design (verified 2026-08-25) |
39
39
  | Passing a data hook's options through a variable or wrapper function: `useRecords(buildOpts())` | **Fails to compile** — the options object must be an inline literal at the call site (verified live 2026-08-25; hit in production, fixed by making the wrapper take the hook's *result* instead). Share `q.select` mappings between hooks, never whole options objects |
40
40
  | Tightening Actions-tab permissions before the block's final redeploy | Every code recompile **resets the auto-registered Actions to default permissions** (verified live 2026-08-25). Tighten permissions after the LAST redeploy, and re-check after any future one |
41
+ | Assuming a comment-only edit is "safe" and leaves Action permissions alone | There is no cosmetic-edit exemption. Any save recompiles, and every recompile rebuilds the Actions at default visibility — a `search_replace` changing nothing but a code comment resets them exactly like a rewrite (verified live 2026-09-09, on two blocks at once). Re-check after EVERY push, including cosmetic ones |
41
42
  | Treating Studio's Actions tab as a separately-managed configuration to keep in sync with code | Actions auto-derive from your `useRecordCreate`/`useRecordUpdate`/`useRecordDelete` + `q.select` on every save. The Actions tab is a read-only inspector; there is no manual delete control. To change an Action, change the code |
42
43
  | One alias in a write-side `q.select` referencing a renamed / non-existent Airtable column | Softr's Action parser silently rejects the **entire** create/update Action — not just the bad alias. Symptoms: Studio's Actions tab shows "No actions used in this block yet", `createRecord.enabled` / `updateRecord.enabled` stays `false`, `.mutate()` calls dispatch but resolve immediately to "not yet ready". Every OTHER field in the same `q.select()` is also lost, even the ones that map cleanly. Diagnostic: bisect the `q.select` — strip down to a known-good minimal set, confirm the Action appears in Studio, then add fields back in halves until it drops out. The culprit is in the last half added. Once narrowed to a single field, grep its name against the freshest Airtable schema export to catch the rename / trailing-space / case-mismatch. Verified 2026-05-21: a `"Photos"` column on Wigs was renamed to `"Before Photos"`, the helper that wrote `photos: "Photos"` had its entire Action disabled even though 11 other fields in the same `q.select` were fine. See [datasources/airtable.md](../datasources/airtable.md#maintainability-gotcha) |
43
44
 
@@ -66,6 +67,9 @@ Run through this catalog before delivering any block. Every row is a violation o
66
67
  | Hardcoded domain in navigation | Relative paths: `/task-details?recordId=...` |
67
68
  | Placing a `<NavigationAction navigation={{ action: "OPEN_CHAT" }}>` Ask-AI button on a block that has no data source connected | Connect the block to the data source the AI should read from in Studio's Source tab. Softr's AI pulls context from the **block that triggered the chat**, not from the page — a button-only helper block with no data source causes `chat/prepare` → HTTP 500 ("Failed to prepare AI assistant") even though the chat UI opens fine. The block doesn't need to read or write records itself; the connection is purely for AI context. Verified by direct experiment, May 2026 |
68
69
  | Emojis in UI | lucide-react icons only |
70
+ | Positioning repeated page chrome (back button, title, primary action) per-block, without checking the pages that already have it | Chrome the user meets on more than one screen is a cross-page contract. Copy the exact offset from the blocks that already ship it, and change every page in one edit. Let the wrapper's padding be the only thing positioning it — `mb-4` and NO top margin on a back button — so one number per page governs it. Verified 2026-09-09: an extra `mt-6` sat one detail page's back button 24px lower than another's, and **each block looked correct in isolation**. See SKILL.md's Block Placement section |
71
+ | A loading skeleton carrying a different border / offset from the component it stands in for | The skeleton must track the component's REST state (border colour, padding, chrome offsets), never its hover state. If they disagree the layout visibly re-draws the instant data lands — the exact thing a skeleton exists to prevent. Verified 2026-09-09 twice in one session: a card grid re-outlined itself on load, and a back button jumped 24px. See [ui-ux-guidelines.md](../ui-ux-guidelines.md) §12 |
72
+ | `focus-visible:` on a card or row that only *contains* buttons | The container is a plain `<div>` and never takes focus, so it is dead CSS. Use `focus-within:` on the container (pairs with its `hover:` treatment) and keep `focus-visible:ring-2` on the button/link itself |
69
73
  | `[&_svg]:opacity-0` on SelectTrigger | `<style>` + `data-fix-chevron` attribute (Softr bundler limitation) |
70
74
  | Relying on `custom-code-header.html` (Softr → Settings → Custom Code → Code inside header) to apply brand fonts/colors INSIDE a Vibe Coding block | Vibe Coding blocks render inside a shadow DOM. CSS custom properties (`--brand-*`) pierce that boundary, but `html, body { font-family: ... !important }` rules **do not** — `<html>` and `<body>` don't exist inside the shadow root. Apply brand fonts/colors at the block's **own outermost wrapper** via inline style: `style={{ fontFamily: "'Manrope', system-ui, sans-serif", color: BRAND_INK }}` on the outer `<div>` so every descendant inherits brand defaults. Override per-element with explicit inline `fontFamily` (e.g., `"'Fraunces', Georgia, serif"` on h1/h2). Google `<link>` tags in the page head DO load `@font-face` globally — the fonts are available inside shadow DOM, they just need to be applied. |
71
75
  | Painting `backgroundColor: BRAND_CANVAS` on a Vibe Coding block's outer wrapper when `custom-code-header.html` already sets `body { background-color: var(--brand-canvas) !important }` | Don't double-paint. If the body bg is already the brand canvas, the block leaves its own backgroundColor unset and the page bg shows through. Painting the same color twice produces a visible seam — Softr's content wrapper sits between `<body>` and the Vibe Coding block, and the two backgrounds composite slightly differently due to sub-pixel rendering, transparency stacking, or wrapper paddings. Set fontFamily and color on the block's wrapper (those don't inherit cleanly through shadow DOM), but **leave backgroundColor unset** — let the page bg flow through. The exception: if the block needs a brand-tinted *section* (e.g., a card-style admin shell that's different from the page bg), paint that bg explicitly on its specific container, not on the outer wrapper. **Second exception — DARK brands: see the next row.** |
@@ -156,6 +156,41 @@ footer [role="list"] > div > div {
156
156
  }
157
157
  ```
158
158
 
159
+ ## Relocating footer nodes, not just restyling them
160
+
161
+ CSS alone cannot move an element to a **different parent**. `order` only reorders siblings, so a
162
+ "put the Website / Terms links down on the copyright line" request — where the links and the copyright
163
+ live in different containers — needs a small JS relocation job in the same Custom Code block, not more
164
+ selectors.
165
+
166
+ The shape that works:
167
+
168
+ 1. Build one flex row, insert it where the copyright currently sits.
169
+ 2. Move the copyright node into it, then the link nodes after it.
170
+ 3. Hide the container the links vacated **only if it is now genuinely empty** — check for remaining
171
+ element children rather than assuming, or you will blank a container that still holds something.
172
+ 4. Run it BEFORE any job that measures height (a footer-compacting pass, a sticky offset calculation);
173
+ those must see the final arrangement or they measure the old one.
174
+
175
+ ```js
176
+ /* relocate, then compact — order matters: compactFooter() measures height */
177
+ relocateFooterLinks();
178
+ compactFooter();
179
+ ```
180
+
181
+ Two details worth copying:
182
+
183
+ - **Truncate rather than wrap.** A relocated row should ellipsise under width pressure
184
+ (`min-width:0` on the flex children plus `text-overflow:ellipsis`), never stack — a footer row that
185
+ reflows to two lines at an awkward width looks broken in a way a clipped label does not.
186
+ - **Scope hover underlines away from logos and icons.** An `::after` underline scaled from
187
+ `transform-origin:50%` gives a centre-out grow; gate it with `:not(:has(svg)):not(:has(img))` so it
188
+ never appears under the logo or the social glyphs, and use `currentColor` so the footer's own colour
189
+ rules keep working untouched.
190
+
191
+ Same caveat as everything else here: **it renders on the published app only.** The Studio editor keeps
192
+ showing the old arrangement, which reads exactly like "the script did not run."
193
+
159
194
  ## Page background
160
195
 
161
196
  **The trickiest one — Softr paints the SAME fill on FOUR stacked layers:** `html`, `body`, `#page-content` (stable id; classes `content spr-content-root`), AND a deeper **class-less wrapper div** nested a few levels inside `#page-content`. Style any one layer and the ones above cover it — this is why setting `body` alone appears to "do nothing."
@@ -83,6 +83,33 @@ Before writing any block code through the MCP, call `get_vibe_coding_docs` — i
83
83
 
84
84
  Editable settings via MCP are the same fields as the block's **Content → Settings** panel; sort and record filters are the same as the **Source** tab. Duplicating from a version is the safe way to try an alternative — the original keeps working while you experiment on the copy.
85
85
 
86
+ ### Which edit tool: full replace vs. targeted search-replace
87
+
88
+ `update_vibe_coding_block_code` sends the whole file; `update_vibe_coding_block_code_search_replace`
89
+ sends only the fragments that change. This is not just a bandwidth choice — it changes what can go wrong.
90
+
91
+ **Reach for search-replace when ONE source file is deployed to SEVERAL blocks.** Datasource UUIDs are
92
+ per BLOCK, not per table, so a file living on two pages needs a different `datasource.define()` pair in
93
+ each. A full replace overwrites that pair and forces a manual swap on every single push — the classic
94
+ way to point a project page at the company page's data. Targeted replacements never touch lines you did
95
+ not name, so **each block keeps its own pair and the swap step disappears entirely** (verified
96
+ 2026-09-09 across a report block deployed to two pages). It is also the safer option on large files:
97
+ retransmitting ~100KB verbatim to change one class string is its own corruption risk.
98
+
99
+ **Reach for the full replace when the change is structural** — reordering JSX, moving logic between
100
+ components, adding a hook — where being sure of "the exact current text" of a dozen scattered fragments
101
+ is harder than being sure of the whole file. Also use it when the local file is the source of truth and
102
+ has drifted from the deployed block in ways you have not enumerated.
103
+
104
+ **Caveat on the `operations` argument.** It is an array of `{search, replace}` objects. Some MCP clients
105
+ serialize it as a JSON *string* instead, and the API rejects that with a Jackson error — `Cannot
106
+ deserialize value of type java.util.ArrayList<java.util.Map<String,String>> from String value` (hit
107
+ 2026-09-09, mid-session, on a tool that had accepted the same shape minutes earlier). It is a client
108
+ serialization quirk, not a bad request: if it will not take the array, fall back to the full replace
109
+ rather than mangling the edit to fit.
110
+
111
+ Both paths recompile, so both reset Action permissions either way (Hard Constraint 21).
112
+
86
113
  ## Adopting Studio-AI-generated code
87
114
 
88
115
  When you pull a Studio-AI-generated block via `get_vibe_coding_block_code` to adopt into a project repo as source of truth: its output renders fine but ships with predictable defects. **Functional patterns in Studio output are platform-support evidence** (it surfaces undocumented capabilities before the docs do — see SKILL.md's "Platform truth sources"); **its code hygiene is not a pattern to imitate.** Cleanup pass before committing:
@@ -390,6 +390,15 @@ Vestibular disorders affect ~35% of adults over 40. Always respect `prefers-redu
390
390
  - Use `<Skeleton />` from shadcn/ui matching the final layout shape.
391
391
  - Pulsing animation is included by default.
392
392
  - Replace content progressively as data arrives.
393
+ - **Match the component's REST state, not just its shape.** A skeleton stands in for a real card, so it
394
+ must carry that card's *resting* border, background and padding -- not its hover state, and not a
395
+ different border "because it is only a placeholder". When the two disagree the grid visibly re-draws
396
+ itself the instant data lands, which is the one thing a skeleton exists to prevent (observed live
397
+ 2026-09-09: skeletons left on the old border colour after the cards moved to a new one).
398
+ - **When an interactive element's resting style changes, change its skeleton in the same edit.** Nothing
399
+ links them -- in a Tailwind + inline-token codebase the colour is literally written twice.
400
+ - **Skeletons repeat page chrome too.** If the page has a back button, breadcrumb or title above the
401
+ content, the skeleton needs those at the SAME offsets, or the chrome jumps when the record arrives.
393
402
 
394
403
  ### Perceived Performance:
395
404
  - **Optimistic UI**: Update the interface immediately, handle failures gracefully. Use for low-stakes actions (likes, filters); avoid for payments or destructive operations.
@@ -550,6 +559,7 @@ Avoid the "hero metric layout template" — big number, small label, supporting
550
559
  - **Landmark hygiene when a block ships page chrome**: shadow DOM does NOT hide landmarks from assistive tech, and Softr's native chrome uses semantic elements — so a block's own `<header>`/`<main>` on a page with native chrome creates duplicate banner/main landmarks. Use plain `<div>`s there; reserve `<header>`/`<main>` for pages where the native chrome is hidden.
551
560
  - Semantic HTML: `<button>` for actions, `<a>` for navigation, `<input>` for data.
552
561
  - **Focus rings:** Never `outline: none` without replacement. Always keep `focus-visible:ring-2`. Focus ring must be 2-3px thick, high contrast, offset from the element.
562
+ - **`focus-within:` for containers, `focus-visible:` for the focusable element itself.** A card or row that *holds* buttons is usually a plain `<div>` and never takes focus, so `focus-visible:` on it is dead CSS that reads like an accessibility feature and does nothing. Use `focus-within:` there, so tabbing to a control inside the card lights the same affordance a mouse user gets from `hover:` -- and keep `focus-visible:ring-2` on the button or link itself. Whenever a card has a `hover:` treatment and contains tab stops, it wants the matching `focus-within:` variant.
553
563
  - Tables: proper `<thead>`, `<tbody>`, `<th scope="col">`.
554
564
 
555
565
  ---
@@ -699,6 +709,9 @@ Actively check for and reject these fingerprints of generic AI-generated interfa
699
709
 
700
710
  - **Smooth transitions:** `transition-all duration-200` on cards and rows
701
711
  - **Hover states on rows/cards:** `hover:bg-muted/50` for interactivity indication
712
+ - **Rest and hover compete for the same colour, and the RESTING state wins.** If a card's resting border already uses your strongest colour, hover has nothing left to change to -- the answer is a *third* colour for hover, never demoting the resting state to free one up. A resting border is permanent and seen by everyone; a hover border exists only for whoever has a pointer on it. (Observed 2026-09-09: freeing the strong colour for hover left the cards reading as unbordered at rest.)
713
+ - **Move hover in the same direction as the rest of the hover treatment.** If the background lightens on hover, the border should lighten too -- a border that hardens while the background softens reads as two effects fighting rather than one card lifting.
714
+ - **Check border colours against the surface they actually sit on.** A hairline tuned for contrast *inside* a white card disappears where that card meets a coloured page ground; the same hex that reads correctly between table rows can be invisible at the card's outer edge.
702
715
  - **Auto-focus first field** when form opens (`autoFocus`)
703
716
  - **Escape to cancel** any modal, dialog, or inline edit
704
717
  - **Confirmation animation:** Brief `Check` icon in save button after success