I shipped ImageStretcher — a free, in-browser tool that stretches width and height independently, without cropping and without uploading your file to a server — using Grok Build as the coding agent.
Coding agents are great at “make it run.” They are mediocre at “make it look like a product” unless you give them something stronger than please make it pretty. Pretty is not a type. Semantic tokens are. Install paths are. Motion presets are. Privacy constraints are.
This article walks through the three levers that kept the UI honest on ImageStretcher:
- Tailwind CSS v4 as a token-first design surface
- Aceternity + shadcn behind a registry guardrail
- Grok Build rules & skills so the agent reloads taste every turn
Stack in one line: React Router v7 SSR · Cloudflare Workers · Tailwind v4 · shadcn/ui · Aceternity UI · Motion springs · multi-locale · light/dark.
1. Write UI priority into the repo, not the chat
Every session, the agent should see the same ladder. Ours lives in always-on Grok Build rules (.grok/rules/00-index.md):
- Tailwind first — layout, spacing, type, responsive: utilities only. No parallel CSS framework.
- Aceternity next — if the Aceternity registry has an equivalent, install that.
-
shadcn
ui/*after that — buttons, cards, predictable chrome. - Hand-roll last.
That order matters. Without it, an agent will invent a one-off dropzone, a second button system, and three blues. With it, the conversation becomes: “add the file stage” → skill loads → npm run shadcn -- add @aceternity/… → component lands where the app actually imports from.
Rules vs skills (Grok Build loading model)
Grok Build picks up project guidance as Markdown under .grok/: rules stay in every session; skills load when the task matches.
.grok/
rules/ # session-always: 00-index, ui-style, core, file-protocol
skills/ # on-demand: shadcn-config, apple-spring, network-proxy, seo-*, …
| Layer | Path | When | What we put there |
|---|---|---|---|
| Rules | .grok/rules/*.md |
Always on | UI priority, visual constitution (ui-style), doc loop, file headers |
| Skills | .grok/skills/*/SKILL.md |
Task-triggered | How to install shadcn/Aceternity, spring motion, SEO step logging, etc. |
Putting taste in the repo — not only in chat memory — is the difference between “agent remembers the product” and “agent rediscovers Bootstrap energy every prompt.”
2. Tailwind v4: tokens are the taste API
ImageStretcher is CSS-first Tailwind v4. Vite plugs it in with @tailwindcss/vite; the design system lives in app/app.css. components.json points shadcn at that file and leaves tailwind.config empty — v4 does not need the old JS config for this setup.
Entry + dark variant + theme bridge
@import "tailwindcss" source(".");
@custom-variant dark (&:where(.dark, .dark *));
@theme inline {
--font-sans: "DM Sans", ui-sans-serif, system-ui, sans-serif;
--font-display: "Fraunces", ui-serif, Georgia, serif;
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
/* …muted, card, border, sidebar, radius… */
}
Three choices worth stealing:
Class-based dark, not media-only.
Theme toggle writes html.dark. The custom variant makes dark: utilities follow that class (and descendants). Users who want “system” still go through the same theme contract; the stylesheet does not fork into two mental models.
@theme inline maps CSS variables to utilities.
Authors (human or agent) write bg-background, text-muted-foreground, font-display — not #f7f4ef in twelve files. When light/dark swap, components do not.
Semantic chrome only.
Light is warm paper (--background: #f7f4ef). Dark is stone (#0c0a09). Primary is teal (#0f766e / dark #2dd4bf). The UI rule is explicit: tool chrome does not get sky/amber/red decoration for “fun.” Status feedback stays muted gray scale or a light primary mix. That single ban kills most AI-generated carnival UIs.
Root font-size 125% (density without zoom)
html {
/* ≈ Cmd++; rem (including Tailwind spacing) scales together — not zoom */
font-size: 125%;
scroll-behavior: smooth;
}
html,
body {
@apply bg-background text-foreground font-sans antialiased;
min-height: 100%;
}
Tool density (labels ~0.6875rem, pills ~0.625rem, toolbar Button size="xs") is specified in rem under ui-style. Bumping the root scales the whole product like accessibility zoom, without breaking position: fixed the way CSS zoom can.
Surfaces: one card language
We use shadcn Card / a small surface-card helper — soft border via color-mix, light shadow, no card-inside-card. Hierarchy is background steps and ring-1 ring-foreground/5, not hard border walls between every section.
Atmosphere (soft primary radials + noise) is a single .app-atmosphere layer on the shell. Agents are not invited to invent a new gradient per section.
Takeaway for agent workflows: if the only legal colors are semantic tokens, bg-blue-500 becomes a lint smell in review — and often never appears.
3. Aceternity + shadcn: registry, path guard, then tame the demo
components.json that matches the framework
{
"style": "new-york",
"rsc": false,
"tailwind": {
"config": "",
"css": "app/app.css",
"baseColor": "zinc",
"cssVariables": true
},
"registries": {
"@aceternity": "https://ui.aceternity.com/registry/{name}.json"
}
}
-
rsc: false— this is React Router SSR, not Next App Router RSC. -
@aceternityregistry — same CLI surface as official shadcn, different package source. - Aliases point at
@/components→app/componentsin practice.
Never bare npx shadcn in this monorepo
The stock CLI loves dumping files into repo-root components/ui or src/components/ui. Our app only imports from app/components/ui.
So installs go through a project script:
npm run shadcn -- add button
npm run shadcn -- add @aceternity/<name>
scripts/shadcn-add.mjs runs npx shadcn@latest …, then moves any files that landed in the wrong components/ui trees back into app/components/ui. That logic is documented in the on-demand skill .grok/skills/shadcn-config/SKILL.md so Grok does not “helpfully” bypass the guard.
Agent constraints from that skill (abridged):
- Prefer
npm run shadcn -- addover hand-pasting registry source. - Never create UI under repo-root
components/. - After add, strip Aceternity demo decoration (blue dashed frames, etc.) to semantic tokens.
- Keep motion parameters restrained in tool chrome.
- Update L3 file headers +
AGENTS.mdwhen UI files change.
Real component: FileUpload on the stretch stage
The preview empty state is an Aceternity-style drop zone (react-dropzone + motion + @tabler/icons-react), consumed by stretch-preview.tsx:
<FileUpload onChange={(files) => onFile(files[0])} />
What we changed from a generic demo:
| Demo default | Product rule |
|---|---|
| Any file / multi |
image/* only, single file |
| Hard-coded English | getTranslations(useLocale()).stretch.upload |
| “Upload to our product” vibe | Local open only — Worker never receives the bitmap |
| Decorative blue | Grid + semantic foreground/muted |
const handleFileChange = (newFiles: File[]) => {
const images = newFiles.filter((f) => f.type.startsWith("image/"));
if (!images.length) return;
setFiles(images.slice(0, 1));
onChange?.(images.slice(0, 1));
};
Copy still says “upload” in the UI sense of choose a file. Privacy copy is unambiguous: processing is canvas-in-browser; leaving the page drops in-memory previews.
shadcn owns chrome; Aceternity owns the moment
-
shadcn:
Button(including densexstoolbar actions),Card, form controls. - Aceternity-style stage: the file drop experience users remember.
-
Motion: not per-component magic numbers. Shared presets in
app/lib/motion.ts:
export const snappy: Transition = {
type: "spring",
stiffness: 400,
damping: 30,
};
export const tapScale = {
rest: { scale: 1 },
pressed: {
scale: 0.96,
transition: { type: "spring" as const, stiffness: 500, damping: 30 },
},
};
gentle / bouncy / smooth cover panels and entrances. The apple-spring skill exists so the agent does not invent a fourth spring language mid-PR.
4. Rules that encode taste; skills that encode procedures
Why “make it pretty” fails
Unconstrained generation optimizes for novelty: gradients, absolute badges, inconsistent radii, random accent colors. A tool site needs the opposite — calm, dense, predictable.
ui-style (always-on) fixes:
- semantic tokens only for chrome
- surface / no nested cards
- rem density scale
-
select-noneon chrome; explicit select on inputs - flex height chains with
min-h-0so preview panes do not collapse on mobile - brand type: Fraunces display + DM Sans body
Those are reviewable. An agent can be wrong against a rule; it cannot be wrong against “vibes.”
File headers as micro-contracts (L3)
Business files carry a short header:
/**
* [INPUT]: Depends on motion, @tabler/icons-react, react-dropzone, ~/lib/utils, ~/i18n
* [OUTPUT]: Exports FileUpload, GridPattern
* [POS]: components/ui Aceternity-style drop zone; consumed by StretchPreview
* [PROTOCOL]: On change, update this header, then check AGENTS.md
*/
Agents search POS/OUTPUT when deciding where to edit. Humans get a map. Directory-level AGENTS.md lists members in one line each. The loop is: change code → refresh header → refresh AGENTS if files moved. We do not paste the whole philosophy doc into every PR — but we do keep the contract surface machine-readable.
The iteration that actually happened
- Skeleton prompt — stretcher, local canvas, no server upload → runnable forms, not shippable UI.
-
UI ladder + shadcn-config skill — install into
app/components/ui, not random folders. - Tokens + ui-style — drop demo blues; primary teal; paper/stone themes.
- Privacy as a hard constraint — no “upload API” invention; Worker only SSR + static.
-
Layout rule —
min-h-0/ flex chain so mobile preview matches desktop calm.
Ugly first drafts can exist on disk for ten minutes. Ugly is not fine on the domain. The rules exist to make that bar enforceable when the agent is moving faster than your eyeballs.
Deploy note (one paragraph)
The app is React Router SSR on a Cloudflare Worker. Images never hit the Worker as uploads; stretch/crop run in the browser. That product decision is also an agent decision: if rules say “no upload backend,” Grok should not scaffold one “for later.”
5. Checklist you can copy into your own agent repo
- [ ] Always-on UI priority (utility CSS → registry showpiece → design-system primitives → hand-roll)
- [ ] Semantic tokens + dual theme (
@theme inline+:root/.dark) - [ ] Class-based dark aligned with your theme provider
- [ ] Registry install guard so components land in the path your aliases use
- [ ] Strip demo decoration from third-party registry components
- [ ] Single motion vocabulary (one
motion.ts, one skill) - [ ] Non-negotiable product rules (ours: no server upload, stretch ≠ crop)
- [ ] Always-on rules under
.grok/rules/so every Grok Build session reloads the same constitution - [ ] On-demand skills under
.grok/skills/for procedures (install, SEO, proxy) so always-on rules stay short
6. Closing
Grok Build did not replace design judgment. It compressed the loop between judgment and pixels.
Tailwind v4 gave us a typed palette in CSS. Aceternity + shadcn gave us velocity with a path guard. Rules and skills gave the agent a constitution it reloads every turn instead of a fading chat memory.
If you want to stretch an image without cropping — width and height free or locked, preview on canvas, download PNG/JPEG, files stay on your device — use the tool:
Public pitch / links: github.com/Bonifacez/imagestretcher.io.
Ship the skeleton fast. Enforce taste in the repo. The domain is what users judge — not your first prompt.
Top comments (0)