Guidance for AI coding agents working in this repository.
React 19, Vite, Tailwind CSS v4, TypeScript, pnpm (npm, yarn and bun are blocked).
Base UI (@base-ui/react), TanStack Router (file-based), clsx + tailwind-merge
(via cn()), lucide-react.
Run pnpm check (typecheck, lint, format check) before finishing a task.
- Import from
srcwith the@/alias (@/components/ui/button,@/lib/utils). - Design source: Figma — SITCON 2027 CFP 網站
(homepage: node
122-336).
- All user-facing literals must live in
src/content.json. This is the single file organizers audit and edit: visible copy, button/link labels, image alt text, accessibility labels, placeholders, dates, units, calendar event titles, browser metadata, and example text. Outbound URLs also live there. - Import it with
import content from '@/content.json'; reference descriptive keys grouped by section. Reuse existing entries for shared copy. - Keep complete sentences in JSON. For dynamic content, use named placeholders
such as
{title}and substitute values in code; do not concatenate hardcoded words or punctuation. Keep markup and layout in React. - Body copy rendered by
LinkedText(session pages, form descriptions) may use[label](https://…)links and**bold**, the only inline markup. Keep the bold of the source document; headings and titles stay plain. - Technical identifiers (route paths, anchors, CSS classes, state values),
calculated numbers, date-format separators, and empty decorative
alt=""attributes may stay in code. Display labels for technical values belong in JSON. - Do not create another copy file or hardcode new copy in TS/TSX/HTML. The HTML title is injected from this JSON by Vite for both development and builds. The one exception is the submission form's questions (below).
The submission form's questions live in src/forms.json, transcribed from
the 表單文案.
fields defines every question once: label, description, kind
(text, email, tel, textarea, radio, checkboxes, select,
consent), required, options, public (adds 【公開】),
showIf and the Google Sheet column it is written to. forms.<type>.sections
lists each session type's questions in order, as keys or as
{ "key": …, …overrides } for a per-type difference. Option labels are also
the values stored in the sheet, as in Google Forms; a consent question is
a single-option confirmation. Field and section descriptions support only
**strong** emphasis, [label](https://…) links and
[label](mailto:…) email links, rendered by LinkedText.
Transcribe bold spans from the source document; background-color annotations
are editorial notes, not emphasis. Keep spans flat: nested markup, general
Markdown, and HTML are not supported. Keep labels and option labels plain,
since option labels sit inside the control and are written to the sheet.
toSheetRow() in src/lib/submission.ts maps
a draft to those columns. forms.<type>.sheet names the sheet tab, and the
top-level sheet holds the metadata columns written before the answers
(投稿編號, 版本, 狀態, …) and the status labels. Form chrome (button labels, validation messages)
stays in content.json. The form has no file uploads: photos, documents
and attachments are collected as links in text or textarea fields, and
their descriptions ask submitters to grant the 議程組 access.
Submissions go to the API Worker in worker/ (README, "Submission API"):
a standalone Cloudflare Worker that writes to the Google Sheet and sends
mail through the Gmail API. It imports src/lib/submission.ts,
src/lib/deadlines.ts, src/lib/session-type-ids.ts and the JSON files, so
keep those free of React, the DOM and import.meta.env; browser-only code
goes in src/lib/submission-client.ts. The API contract is in
src/lib/submission-api.ts. Email copy lives in content.submission_email,
the verify page's in content.submit_verify. /submit/verify confirms an
address, and /submit/$type?edit=… opens an edit link. Without
VITE_SUBMISSION_API, the form stays a labelled mock; with it, sends also
need the Turnstile widget (VITE_TURNSTILE_SITE_KEY, turnstile.tsx), a
Cloudflare iframe that is the one control not built on Base UI. Run pnpm test after
changing the flow.
Homepage schedules live in content.attend_section.tickets and
content.speaker_section.schedule. Each item's start / end is an ISO
timestamp with an explicit timezone (e.g. 2027-01-05T23:59:00+08:00), or
null until announced; a single moment sets only one of them. The status
badges (即將開始 / 進行中 / 已結束) are computed from the dates; an
unannounced item shows no badge and no date. The
speaker item with id submission supplies the homepage submission deadline
too, so keep that id stable. Its end also closes submissions:
useSubmissionClosed() (src/lib/countdown.ts) turns every /submit/…
CTA into a disabled 投稿已截止 Button (footer links into dimmed text) and
replaces the form with the content.submit_closed message, live when the
deadline passes; a null end keeps submissions open.
The session-type quiz lives in content.session_quiz.questions. Each answer
carries scores for the four results (espresso, presentation, open,
demo; see content.session_quiz.results), and the highest total wins. On a
tie, the result picked most recently as an answer's top score wins (Q3, then
Q2, then Q1). Espresso and Presentation both link to the 一般議程 page; the
mapping is in src/lib/session-quiz.ts.
src/components/ui/— reusable UI primitives (button, tabs, dialog, tooltip, …). One file per primitive, named in kebab-case (button.tsx,tabs.tsx).src/components/— site-level components composed from the primitives (header, footer, session card, …).src/routes/— one file per route, picked up by TanStack Router's Vite plugin (index.tsx→/,about.tsx→/about). Each exportsRoute = createFileRoute('<path>')({ component });__root.tsxis the layout. Use<Link to="…">for internal links (type-checked paths).- Production lives under
/2027/cfp/(see README, "Production deployment"). Never write a root-relativehref="/…"orsrc="/…": route through<Link>(or a primitive'srender={<Link … />}) and import assets, so the base path is applied. A new route must also be added torouteShellsinvite.config.ts, or direct visits to it 404 in production. - In-page jumps to a section use
<Link to="/" hash="attend">, never a raw<a href="#attend">. The router smooth-scrolls hash links (defaultHashScrollIntoViewinsrc/main.tsx, instant underprefers-reduced-motion), andscroll-pt-headeronhtmlkeeps the target clear of the header. Do not addscroll-behavior: smoothin CSS (it would also animate the router's scroll to top on page changes) or a smooth-scroll library. src/routeTree.gen.tsis generated by the Vite plugin wheneverpnpm devorpnpm buildruns. Never edit it by hand, but do commit it:tscneeds it.- Routes compose components from the places above; do not put primitives inline in routes.
- Any interactive element that affects user experience (buttons, tabs, menus,
dialogs, popovers, tooltips, selects, checkboxes, switches, inputs, …) must
be built on Base UI (
@base-ui/react). - Do not hand-roll these behaviors with raw
<div>/<button>plus custom keyboard, focus or ARIA handling. Base UI provides accessibility and interaction logic; we provide the look. - Check the Base UI docs for the component's anatomy and props before writing it.
Version-matched docs ship in the package:
node_modules/@base-ui/react/docs/react/components/<name>.md. They override anything you remember about Base UI. - Never render a link (
<a>) throughButton'srenderprop — Base UI forbids it. Style the<a>withbuttonVariants()from@/components/ui/buttoninstead. - Do not add other headless or pre-styled UI libraries (Radix, shadcn/ui, Headless UI, MUI, …).
- Primitives live in
src/components/ui/in the shadcn style: a thin wrapper around a Base UI part that owns its own Tailwind classes. The code lives in our repo and is meant to be edited. - We do not use shadcn/ui or any pre-styled component library. The site's design is heavily customized, so write styles for our design instead of adopting a library's defaults.
- Style with Tailwind utility classes. Style Base UI states through its data
attributes: Tabs
data-active:, Radiodata-checked:, popup triggersdata-popup-open:, everythingdata-disabled:, orientationdata-[orientation=vertical]:. Check the docs for the exact attribute names. - Primitives accept a string
classNameand merge it withcn()from@/lib/utils. Wrappers type it asWithClassName<X.Props>from@/lib/utils. - Focus ring for every interactive element:
focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue. The exception isInputandTextarea, which follow Figma's 表單 Page: focus lightens the border instead of showing the blue ring.
All tokens live in the @theme block of src/index.css. Tailwind's default
colors, type scale and radii are reset, so bg-white, text-sm,
rounded-lg (the default one) and friends do not exist.
- Every size is a token. Colors, font sizes, line heights, font
families, radii, spacing and component dimensions all come from
@theme, so the whole design can be retuned fromsrc/index.cssalone. Arbitrary values —bg-[#…],text-[24px],rounded-[30px],h-[59px],p-[13px],font-['…'],leading-[…]— are forbidden, with no exceptions for "one-off" sizes. (Selectors likedata-[orientation=vertical]:and Base UI runtime variables likeh-(--popup-height)are not sizes and are fine.) - Spacing comes from the
--spacingbase. Write spacing classes from the Figma px ÷ 4, as if it were Tailwind's 4px scale. The design is on a 5/10px grid: Figma 10px =2.5, 20px =5, 30px =7.5, 50px =12.5. The base is actually 3px, so these render at ×0.75 (see below). Changing--spacingrescales all padding, gaps and spacing-based sizes at once. - All size tokens are in
rem(px ÷ 16; the px value is in a comment next to each one), so the site follows the browser's font-size setting. Write new size tokens in rem too. - Reuse before adding. If a value is already a token, use it, even if
the name doesn't mention your component (tabs use
h-controlandtext-paragraph, just like buttons). Snap values within a few px of an existing token to that token instead of minting a new one. - New tokens only when necessary. Add one only when a Figma value can't be
expressed by an existing token or by the spacing scale, and name it
semantically after its role (
control,content), never after its value (59,big-2). Never add a token that has no source in Figma — ask instead. - Adding a token requires all of: (1) add it to
@themeinsrc/index.css, scaled from Figma (see "Figma sizes vs. token sizes"), in rem, plus a mobile override if it should shrink on small screens, (2) if it is a newtext-*size or named--spacing-*size, register it in the tailwind-merge config insrc/lib/utils.ts(otherwisecn()drops it when combined with a text color), (3) add it to the tables here with its Figma, desktop and mobile values.
| Token | Value | Figma variable | Use |
|---|---|---|---|
background |
#101312 |
Background | Page background, dark text on cream, dark button |
foreground |
#F9F5EF |
Foreground | Cream surfaces and light text |
gray |
#2B2B2B |
Gray | Dark card, secondary text on cream, rules |
light |
#E3E0DC |
Light | Muted button, quiz wells |
red / blue / green |
#FF625B / #6A74E3 / #8DF280 |
Red / Blue / Green | Hero accent buttons; blue is also the focus ring |
yellow |
#FCBF30 |
Yellow | Early Bird marquee band |
black |
#000000 |
Grays/Black | Overlays only (navbar glass bg-black/35) |
The Figma names are kept as-is, so on cream sections the text color is
text-background. That is intended.
Figma is drawn on a 1280px frame with print-scale type (24px body copy,
80px display), which looks oversized in a browser. The tokens are therefore
not the Figma values: they are scaled to about ×0.75 on desktop,
with small text (≤ 16px) kept as-is for legibility, and shrink again on
mobile (below md, 48rem) through the overrides at the bottom of
src/index.css.
When implementing from Figma:
- Map by role, not by number. A 24px Figma text is
text-paragraph, a 59px control ish-control, a 50px corner isrounded-*-xl, even though the rendered sizes differ. Use the "Figma" column below to find the token. - Spacing: keep writing Figma px ÷ 4 (
p-5for 20px); the 3px base does the scaling. - Do not "fix" a token back to its Figma value, and do not add a token
just because a rendered size doesn't match Figma. Retune the scale in
src/index.cssinstead. - Responsive sizes live in the tokens. Components don't need
md:text-*just to resize text for mobile; use breakpoints only for layout. - Images rendered at their intrinsic size (SVG logos, backdrop icons, the quiz mascot) don't follow the scale.
Sizes in px (tokens are rem). Line heights are the same at every size unless a mobile value is given.
| Token | Figma | Desktop | Mobile | Use |
|---|---|---|---|---|
font-sans |
— | LINE Seed TW → Inter | Everything (default) | |
font-numeric |
Inter Medium | Inter | Dates and countdown digits | |
text-mega |
— (not in Figma) | 136 / 1.05 | 52 | Homepage "Call For Papers" |
text-headline |
— (not in Figma) | 96 / 1.1 | 40 | 甚麼是 SITCON ? headline |
text-stat |
— (not in Figma) | 96 / 1 | 56 | 甚麼是 SITCON ? numbers |
text-display |
80 / 1.25 | 60 | 40 | Section titles (SectionHeading) |
text-h1 |
48 / 1 (h2) |
36 | 28 | Sub-headings (到現在,我們累積了……) |
text-eyebrow |
40 / 1 | 30 | 22 | "SITCON 2027" |
text-h2 |
36 / 40px | 28 / 1.1 | 22 | Centered titles |
text-h3 |
32 / 1.5 (h3) |
24 | 20 | Card titles |
text-lead |
28 / normal | 20 | 18 | Large button |
text-paragraph |
24 / 2 (paragraph) |
18 | 16 / 1.75 | Body copy, buttons, quiz |
text-subheading |
20 / 1.5 | 18 | 16 | Footer column titles |
text-body |
16 / 1.5 | 16 | 14 | Navbar, footer links |
text-caption |
12 / 1.5 | 12 | 12 | Footer fine print |
Weights: font-normal (400), font-bold (700), font-extrabold (800).
font-medium is for Inter only.
LINE Seed TW is self-hosted. The official TTFs (from
seed.line.me, OFL, fonts/line-seed-tw/) are
imported with ?subsets in src/main.tsx, and vite-plugin-font
(vite.config.ts) subsets them at build time to the characters in src/
plus ASCII and common full-width punctuation (whiteList), split into
unicode-range chunks. New copy in content.json is picked up
automatically. A character typed into the form that the subset lacks falls
back to Inter or the system font. Do not load fonts from a CDN.
The plugin's native binary is downloaded by the root postinstall, pinned
to a version; cn-font-split's own install script (which fetches the
latest release) is disabled in pnpm-workspace.yaml. The binary also keeps
Node from exiting, so the exit-after-build plugin ends vite build.
| Token | Figma | Desktop | Mobile | Use |
|---|---|---|---|---|
rounded-*-xs |
5 | 4 | 4 | The sharp corner of buttons |
rounded-*-sm |
14 | 10 | 8 | Inner corners of grouped tiles; default Card |
rounded-*-md |
30 | 22 | 18 | Button corners |
rounded-*-lg |
40 | 30 | 24 | Quiz wells and answer pills |
rounded-*-xl |
50 | 36 | 28 | Outer corners of cards |
rounded-full |
— | — | — | Navbar, icon buttons |
max-w-content |
970 | 970 | 970 | Content column (mx-auto max-w-content) |
| Token | Figma | Desktop | Mobile | Use |
|---|---|---|---|---|
--spacing |
4 | 3 | 3 | Base of the spacing scale (p-5, gap-2.5, size-6, …) |
h-control |
59 | 44 | 40 | Default control height: Button md, tab tiles (min-h-control) |
h-control-lg |
86 | 64 | 52 | Large control height: Button lg, navbar |
size-control-icon |
45 | 36 | 36 | Icon-only buttons |
pt-header |
126 | 94 | 82 | Height of the overlaid site header; pages pad their top with it. Derived: spacing × 10 + control-lg |
Corner rule. Tiles that sit next to each other (side by side or stacked)
read as one shape: their outer corners are xl and their inner
corners are sm. Examples: deadline + countdown (rounded-l-xl /
rounded-r-xl), about image + text, session-type stack + detail panel
(rounded-tl-xl on the first tile, rounded-bl-xl on the last,
rounded-r-xl on the panel). Set these per tile at the call site, e.g.
<Card className="rounded-l-xl">. TabsList applies the rule to its tabs
automatically.
Section layout. Sections are built so their edges line up down the page and spacing reads as one rhythm. Copy the shell from an existing section of the same kind instead of picking new numbers.
- One gutter, one column. Every section has the same screen-edge
gutter and the same centered
max-w-contentcolumn with the same inner padding, so the text starts at the same x on every section, every page. Never pad one section's column differently to nudge its content. - Each kind of spacing has one owner. The section owns its background,
full-bleed width and the space above and below it. The column owns the
rhythm between its children (one
gap). A child that needs a larger break adds padding on itself. Don't mix in margins or change the gap per child. - Same kind, same spacing. Sections that play the same role (homepage sections, sub-page sections, a page's first section under the header) share the same vertical padding. Deviate only where Figma does, and say why in a comment.
- Full-bleed pieces rejoin the column. Elements that break out to the screen edge (the photo wall, slanted edges) put any text or controls they carry back inside the shared column.
- Stay on the grid. All spacing comes from the 5/10px Figma grid (see "Spacing" above). Snap off-grid Figma values to the nearest step.
- Icons: use
lucide-reactwhenever an equivalent exists (the design is drawn with Lucide). - Brand logos (Facebook, Instagram, Telegram, Flickr, YouTube, the SITCON
logo) are not in Lucide: export them from Figma as SVG into
src/assets/and import them. Never hand-draw or paste approximate paths. - Photos are optimized at build time by
vite-imagetools(presets invite.config.ts, types insrc/images.d.ts). Commit the photo as a JPG, PNG or WebP, at least as wide as it renders on a 2× screen (up to 1600px); don't make your own sizes, formats or thumbnails. Import it with?responsive(also asquery: '?responsive'inimport.meta.glob) and render it withResponsiveImagefrom@/components/responsive-image, passingsizesfor its rendered width. It makes a<picture>of AVIF and WebP at several widths, lazy by default; the photo at the top of a page takesloading="eager"andfetchPriority="high".?placeholdergives a tiny inlined preview (PageHero'splaceholder), andpreloadImagein@/lib/preload-imagefetches a photo ahead of time. SVGs are imported plainly and rendered with<img>.
The homepage 甚麼是 SITCON ? section scrolls a wall of past-conference
photos (src/components/home/photo-wall.tsx). Its photos are every image in
src/assets/photo-wall/, in filename order. To show some large, list
their file names in about_section.photoWallLarge in content.json; they
take the large tiles in that order. Export photos at about 1600px on the
long side. With the
folder empty, the session photo stands in at different crops.
With a mouse, hovering pauses the wall and makes the photo under the cursor
glow: the rest dims, with light spilling past the photo's edges
(spotlight-shade in src/index.css). The glow glides
between photos and fades with ease-smooth. Touch
devices get the plain wall.
The 投稿主題範例 section on /sessions/$type reads its cards from
content.session_page.examples.<type>: either items (one carousel) or
groups, each a title with its own items (一般議程's Presentation and
Espresso). Each card has title, href and image, a file name in
src/assets/session-examples/. An empty items list shows the 範例整理中
message.
Regions that are not designed yet, or are marked WIP in Figma, render
<Placeholder label="…" /> from @/components/placeholder. Do not invent a
design for them. There are none at the moment.
The 歷年海報展示 strip on /sessions/demo shows every image in
src/assets/demo-posters/, newest file name first (2026-1.webp before
2025-1.webp). They are past posters from the 2026 and 2025 sites
(sitcon-tw/2026 public/img/poster/, sitcon-tw/2025 public/poster/),
exported at 1000px tall.