A read-only delivery dashboard over the GitLab REST API v4 (Epics → Stories → Tasks, Milestones and a milestone calendar). Built with Nuxt 4, Nuxt UI, Pug and TypeScript.
  • TypeScript 67.7%
  • Vue 17.4%
  • JavaScript 8.5%
  • CSS 6.4%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Sébastien Denooz eebdd87828 feat(ui): apply the brand charter to the whole application
`brand.css` holds the charter as delivered — colour ramps, gradients, type
scale, radii, shadows and motion — and is excluded from Prettier so it stays
diffable against the `design-tokens.json` it is generated from. `main.css`
bridges those tokens onto Nuxt UI's `--ui-*` variables and `app.config.ts` names
the palette behind each semantic colour, so components keep using `primary`,
`secondary`, `info` and `neutral` rather than raw palette names.

Two mappings need stating explicitly: Nuxt UI picks shade 500 for light mode
while the charter's brand colours are the 600 step, and its dark palette is a
neutral grey where the charter calls for the near-black indigo of the logo. The
page now sits on the charter surface with cards on the raised one, in both
modes. Headings use Space Grotesk, body text Inter and code JetBrains Mono, all
self-hosted; the login title carries the spectrum gradient, one per screen as
the charter asks; and a global `:focus-visible` rule paints the aqua ring at
2px with 2px offset, unlayered so no component can drop it.

Three deviations from the token file, each for a reason:

- the numeric `--spacing-*` scale is exposed as `--brand-space-*` instead. In
  Tailwind v4 it would redefine `p-5`…`p-10` while `p-11` and beyond kept the
  0.25rem step, making `py-8` (4rem) larger than `py-12` (3rem);
- the neutral ramp travels as `ink`, because Nuxt UI routes the name `neutral`
  to its own internal palette;
- `info` uses aqua 900 and `success` emerald 600: the charter's aqua 700 is
  2.75:1 on white — its own contrast table marks it insufficient — and #0E9F6E
  is 3.39:1, both below AA for the text Nuxt UI paints with them. `warning` and
  `error` land on the charter's exact values.
2026-08-26 17:17:28 +02:00
.serena Init serena and add task 2026-06-09 11:16:15 +02:00
app feat(ui): apply the brand charter to the whole application 2026-08-26 17:17:28 +02:00
scripts feat(reactions): star epics and stories with GitLab emoji reactions 2026-08-25 13:34:35 +02:00
server feat(reactions): star epics and stories with GitLab emoji reactions 2026-08-25 13:34:35 +02:00
shared feat(analytics): credit the studio and add consent-gated Google Analytics 2026-08-26 12:48:42 +02:00
tests feat(analytics): credit the studio and add consent-gated Google Analytics 2026-08-26 12:48:42 +02:00
.env.example feat(analytics): credit the studio and add consent-gated Google Analytics 2026-08-26 12:48:42 +02:00
.gitignore fix(deploy): run via the Node server instead of static generate 2026-06-09 14:12:45 +02:00
.npmrc chore(scaffold): set up Nuxt 4, Nuxt UI, Pug and the tooling baseline 2026-06-09 11:42:13 +02:00
.prettierignore feat(ui): apply the brand charter to the whole application 2026-08-26 17:17:28 +02:00
.prettierrc.json chore(scaffold): set up Nuxt 4, Nuxt UI, Pug and the tooling baseline 2026-06-09 11:42:13 +02:00
CLAUDE.md feat(reactions): star epics and stories with GitLab emoji reactions 2026-08-25 13:34:35 +02:00
eslint.config.mjs feat(auth): add login, base-group selection and the app shell 2026-06-09 12:05:05 +02:00
nuxt.config.ts feat(analytics): credit the studio and add consent-gated Google Analytics 2026-08-26 12:48:42 +02:00
package-lock.json feat(analytics): credit the studio and add consent-gated Google Analytics 2026-08-26 12:48:42 +02:00
package.json feat(analytics): credit the studio and add consent-gated Google Analytics 2026-08-26 12:48:42 +02:00
playwright.config.ts feat(transport): fall back to browser-direct GitLab calls without a server 2026-08-20 12:28:58 +02:00
playwright.direct.config.ts feat(transport): fall back to browser-direct GitLab calls without a server 2026-08-20 12:28:58 +02:00
README.md feat(ui): apply the brand charter to the whole application 2026-08-26 17:17:28 +02:00
task.md Update task 2026-06-09 11:18:12 +02:00
tsconfig.json chore(scaffold): set up Nuxt 4, Nuxt UI, Pug and the tooling baseline 2026-06-09 11:42:13 +02:00
vitest.config.ts chore(scaffold): set up Nuxt 4, Nuxt UI, Pug and the tooling baseline 2026-06-09 11:42:13 +02:00

GL Dashboard

A delivery dashboard over the GitLab REST API v4 (Epics → Stories → Tasks, Milestones and a milestone calendar). Built with Nuxt 4, Nuxt UI, Pug and TypeScript.

Branding

The visual identity comes from the brand charter, kept as delivered in app/assets/css/brand.css (generated from the charter's design-tokens.json, and excluded from Prettier so it stays diffable against its source). app/assets/css/main.css bridges those tokens onto Nuxt UI, and app/app.config.ts names the palettes each semantic colour uses.

Charter Where it lands
indigo / violet / magenta / aqua / neutral ramps primary, secondary, info, neutral — components only ever use semantic names
Space Grotesk, Inter, JetBrains Mono headings, body, code — self-hosted by @nuxt/fonts
Type scale, radii, shadows, motion Tailwind's --text-*, --radius-*, --shadow-*, --duration-*
Light/dark surfaces --ui-bg (raised, cards) and --ui-bg-muted (page) in both modes
Aqua focus ring, 2px, offset 2px a global :focus-visible rule, unlayered so components cannot drop it
spectrum gradient the login title — one signature gradient per screen, as the charter asks

Three points where the integration deviates from the token file, each for a reason:

  • Spacing. The charter's --spacing-* scale is exposed as --brand-space-* instead. In Tailwind v4 it would redefine p-5p-10 while p-11 and beyond kept the 0.25rem step, so py-8 (4rem) would end up larger than py-12 (3rem).
  • Neutral. Nuxt UI routes the name neutral to its own internal palette, so the charter's neutral ramp travels as ink and is wired to --ui-color-neutral-* explicitly.
  • Two colours are shifted for contrast. info uses aqua 900 rather than the charter's aqua 700, which its own contrast table marks insufficient (2.75:1); success uses emerald 600, since #0E9F6E is 3.39:1 on white and Nuxt UI paints badge text with it. warning and error land on the charter's exact values.

To update the identity, regenerate brand.css from the charter and re-check the bridge in main.css.

Access token scopes

Reading the dashboard needs read_api. Starring an epic or a story writes a emoji reaction back to GitLab, which needs the api scope — that is the only write the dashboard performs. With a read_api token everything still renders and the star buttons are disabled, with a tooltip saying why.

Stars and "My epics/stories"

A star is not stored in the dashboard: it is the (star) emoji reaction on the epic or issue in GitLab, so it is visible in GitLab itself and follows you across browsers and deployments.

  • Starred epics lead the Epics page in their own group.
  • My epics/stories lists every epic and story in the base group carrying any reaction of yours — starred ones first, then anything you reacted to with another emoji.
  • Epics and stories can be laid out boxed (cards) or inline (compact rows); the choice is remembered in a cookie.

Reactions are read with GitLab's my_reaction_emoji filter on the epics and issues endpoints — a couple of requests per load, not one per epic and story.

Set NUXT_PUBLIC_SUPPORT_URL to a payment/donation link (e.g. a Stripe payment link) to show a small "Support this project" button in the bottom-right corner, next to the "Made with ❤️ by Alchemic.studio" credit. Leave it unset and the button is not rendered; only http(s) URLs are accepted.

NUXT_PUBLIC_SUPPORT_URL="https://buy.stripe.com/…" npm run generate

In proxy mode the variable is read when the server starts. A static npm run generate build has no server, so the value is baked in at build time — set it on the build command, as above.

Analytics is off unless NUXT_PUBLIC_ANALYTICS_ID holds a GA4 measurement id (G-…). With no id there is no tracking and no cookie banner at all.

NUXT_PUBLIC_ANALYTICS_ID="G-XXXXXXXXXX" npm run generate

When an id is set, nuxt-gtag is used, but nothing reaches Google until the visitor accepts:

  • the module's own gtag.id is left empty and initMode is manual, so its plugin never bootstraps and no script is injected on its own;
  • a consent bar asks first, with Accept and Decline given equal weight;
  • on accept, Consent Mode v2 defaults are queued (analytics_storage: granted, every advertising signal denied — this dashboard does not advertise) and only then is gtag.js injected, so the very first hit is already a consented one;
  • the answer is stored for a year in the first-party gl-cookie-consent cookie and re-applied on later visits without asking again;
  • a "Cookies" link next to the credit reopens the choice; withdrawing stops collection (ga-disable-<id>), sends a denied consent update and clears the _ga cookies.

The dashboard's own storage — the gl_session cookie (proxy mode), the gl-dashboard:session entry in sessionStorage (direct mode) and the gl-view-mode layout cookie — is what makes the app work and is not part of the analytics consent.

Loading feedback

Long reads (the delivery tree, milestones, reactions) show skeletons plus a tiny status line saying what is being read. In browser-direct mode that line follows the actual GitLab calls (stories for epic 3 of 12, linking tasks · 7 of 11 stories); in proxy mode the whole aggregation is one server request, so the page shows its own coarse label instead. The refresh button in the top bar re-reads all three.

Deployment modes

The dashboard runs in either of two modes. It picks one at runtime by probing /api/auth/session once, so you deploy the build that matches your host and the app adapts.

Mode How GitLab is reached Where the token lives Needs
Proxy (recommended, default) Nitro server routes (/api/**) call GitLab server-side Sealed httpOnly session cookie A Node process
Direct (static hosting) The browser calls https://<instance>/api/v4/** itself sessionStorage, for that tab only GitLab reachable from the browser, over the same scheme

Force a mode with NUXT_PUBLIC_GITLAB_MODE=proxy|direct; the default is auto.

Proxy mode — build + Node server

npm install
npm run build      # produces ./.output (server + client)
npm run start      # = node .output/server/index.mjs  → http://localhost:3000

To run it on another machine, copy the whole .output/ folder there (Node 20+ required) and run node .output/server/index.mjs. Open the printed URL, enter your GitLab instance URL + a personal access token, and log in.

Set a strong session secret in production (≥ 32 chars):

NUXT_SESSION_PASSWORD="<a-long-random-string>" node .output/server/index.mjs

Behind nginx, proxy the whole app to that process:

location / {
    proxy_pass http://127.0.0.1:3000;
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-Proto $scheme;   # so the session cookie gets `Secure`
}

Direct mode — static hosting, no server

npm run generate   # = NUXT_PUBLIC_GITLAB_MODE=direct nuxt generate

Upload .output/public/ to any static host and serve it as an SPA:

root /var/www/gl-dashboard;
location / {
    try_files $uri $uri/ /index.html;
}

What this trades away, and what it requires:

  • The personal access token is held in the browser tab's sessionStorage (cleared when the tab closes) instead of a server-side session. The login screen says so.
  • Same scheme both ways: an https page cannot call an http:// GitLab — the browser blocks it. The login form rejects that combination with an explanation rather than letting it fail as a generic network error.
  • The GitLab instance must answer cross-origin API calls. GitLab does this for /api/v4/** out of the box (Access-Control-Allow-Origin: *, private-token allowed, pagination headers exposed) — check any reverse proxy in front of it does not strip those headers.
  • The instance must be reachable from the browser, not just from your server (a LAN-only instance will not work for remote users).
  • Chrome logs 404s for GET /groups/:id/epics on Community Edition (the tree probe, plus the two reaction filters). Those probes are deliberate — they are how the dashboard detects that native Epics need Premium/Ultimate — and in direct mode the browser makes them, so they show up in the console.
  • Starring works in this mode too: GitLab's CORS policy allows POST/DELETE on /api/v4/** with the private-token header.

Develop

npm run dev               # http://localhost:3000 with HMR (proxy mode)
npm run lint              # ESLint
npm run typecheck         # vue-tsc
npm run test              # Vitest unit tests
npm run test:e2e          # Playwright E2E against the dev server (proxy mode)
npm run test:e2e:direct   # Playwright E2E against a browser-direct SPA server

Test data

npm run seed provisions a representative dataset on the test instance and mints an api-scoped token — see scripts/README.md.