Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
494640c1a8 | ||
|
|
6d27d730d2 | ||
|
|
b0ab6ee5c0 |
+20
-3
@@ -5,7 +5,17 @@
|
||||
# manager and no libc — the attack surface of the running container is the binary.
|
||||
|
||||
# --- 1. the web interface ----------------------------------------------------
|
||||
FROM node:22-alpine AS web
|
||||
#
|
||||
# Pinned to the *build* platform, not the target. What this stage produces is
|
||||
# HTML, CSS and JavaScript, which are the same bytes whatever the image will run
|
||||
# on — so building it once and copying it into both architectures is not an
|
||||
# optimisation, it is the correct thing.
|
||||
#
|
||||
# Without the pin, buildx runs this stage once per target, which means running
|
||||
# Node and esbuild under QEMU for arm64. That crashes: exit 132, SIGILL, an
|
||||
# instruction the emulator does not implement, roughly one release in three. It
|
||||
# looks like a broken build and is a broken emulator.
|
||||
FROM --platform=$BUILDPLATFORM node:22-alpine AS web
|
||||
|
||||
WORKDIR /src
|
||||
COPY web/ ./
|
||||
@@ -24,7 +34,12 @@ RUN if [ -f package.json ]; then \
|
||||
fi
|
||||
|
||||
# --- 2. the binary -----------------------------------------------------------
|
||||
FROM golang:1.26-alpine AS build
|
||||
#
|
||||
# Also pinned to the build platform, and cross-compiled instead. Go does that
|
||||
# natively and CGO is already off, so there is nothing to emulate — where
|
||||
# building *in* the target architecture means running the whole Go toolchain
|
||||
# under QEMU for one of the two.
|
||||
FROM --platform=$BUILDPLATFORM golang:1.26-alpine AS build
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
@@ -37,11 +52,13 @@ COPY . .
|
||||
COPY --from=web /src/build ./cmd/verdande/webbuild
|
||||
|
||||
ARG VERSION=dev
|
||||
# Supplied by buildx per target. The default keeps a plain `docker build` working.
|
||||
ARG TARGETARCH=amd64
|
||||
|
||||
# CGO off is what makes this binary static, and it is only possible because the
|
||||
# SQLite driver is pure Go. -trimpath and the empty buildid keep the output
|
||||
# reproducible; -s -w drop the symbol and DWARF tables, which is most of the size.
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build \
|
||||
RUN CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} go build \
|
||||
-tags embedweb \
|
||||
-trimpath \
|
||||
-ldflags="-s -w -buildid= -X main.version=${VERSION}" \
|
||||
|
||||
@@ -804,6 +804,56 @@ test('en rolle kan rettes uden at fjerne personen', async ({ browser, page }) =>
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* The project page on a phone.
|
||||
*
|
||||
* Every other test here runs at desktop width, and this is what that misses: the
|
||||
* header is a flex row of a title and four controls, and 390px does not have room
|
||||
* for them. Without wrapping, the heading was squeezed to its min-content — which
|
||||
* with `overflow-wrap: anywhere` is one character — so a project called
|
||||
* "Skovvænget" rendered as a vertical column of letters while the buttons ran off
|
||||
* the right edge. It looked like a broken font.
|
||||
*/
|
||||
test('projektsiden er brugbar på en telefon', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto('/');
|
||||
|
||||
// The sidebar is a drawer at this width, so the project is reached through it.
|
||||
await page.getByRole('button', { name: 'Vis menu' }).click();
|
||||
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
|
||||
await sidebar.getByRole('button', { name: 'Nyt projekt' }).click();
|
||||
await sidebar.getByLabel('Projektnavn').fill('Skovvænget');
|
||||
await sidebar.getByLabel('Projektnavn').press('Enter');
|
||||
|
||||
const heading = page.getByRole('heading', { name: 'Skovvænget' });
|
||||
await expect(heading).toBeVisible();
|
||||
|
||||
const box = await heading.boundingBox();
|
||||
// Wide enough to hold the word, and short enough not to be a column of
|
||||
// letters. One line of "Skovvænget" at --text-2xl is nowhere near 200px tall.
|
||||
expect(box.width, 'overskriften er klemt sammen').toBeGreaterThan(120);
|
||||
expect(box.height, 'overskriften er brudt op i én bogstavkolonne').toBeLessThan(120);
|
||||
|
||||
// And nothing sticks out past the right edge of the page.
|
||||
const sideways = () =>
|
||||
page.evaluate(
|
||||
() => document.documentElement.scrollWidth - document.documentElement.clientWidth
|
||||
);
|
||||
expect(await sideways(), 'siden kan scrolles vandret').toBeLessThanOrEqual(1);
|
||||
|
||||
// The task drawer, too. It was `width: 100vw`, which includes the scrollbar
|
||||
// gutter where a percentage does not, so the drawer came out wider than the
|
||||
// page and the whole document could be pushed sideways.
|
||||
await page.getByLabel('Ny opgave').fill('mal væggen');
|
||||
await page.getByLabel('Ny opgave').press('Enter');
|
||||
await page.getByText('mal væggen', { exact: true }).click();
|
||||
await expect(page.getByRole('complementary', { name: 'Opgave' })).toBeVisible();
|
||||
expect(await sideways(), 'opgaveruden skubber siden sidelæns').toBeLessThanOrEqual(1);
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* The MCP connector address must not fall through to the app shell.
|
||||
*
|
||||
|
||||
@@ -18,7 +18,13 @@
|
||||
newName = '';
|
||||
adding = false;
|
||||
const project = await app.createProject(name);
|
||||
if (project) goto(`/projekt/${project.id}`);
|
||||
if (!project) return;
|
||||
// Closes the drawer as well as navigating. On a phone the sidebar is an
|
||||
// overlay, and creating a project moved the page underneath it while leaving
|
||||
// it covering the screen — every link in here closes it, and this makes the
|
||||
// same move for the same reason.
|
||||
onnavigate?.();
|
||||
goto(`/projekt/${project.id}`);
|
||||
}
|
||||
|
||||
async function signOut() {
|
||||
|
||||
@@ -575,7 +575,12 @@
|
||||
.drawer {
|
||||
position: fixed;
|
||||
inset: 0 0 0 auto;
|
||||
width: min(480px, 100vw);
|
||||
/* `100%` rather than `100vw`. For a fixed element a percentage resolves
|
||||
against the initial containing block, which excludes the scrollbar gutter
|
||||
— `100vw` does not, so the drawer was a few pixels wider than the page and
|
||||
the whole document could be pushed sideways. */
|
||||
width: min(480px, 100%);
|
||||
max-width: 100%;
|
||||
z-index: 80;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -625,7 +630,12 @@
|
||||
|
||||
.scroll {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow-y: auto;
|
||||
/* Nothing inside may widen the drawer. A pasted URL in a comment or a long
|
||||
unbroken word would otherwise stretch the flex column and take the page
|
||||
with it. */
|
||||
overflow-x: hidden;
|
||||
overscroll-behavior: contain;
|
||||
padding: var(--s4) var(--s4) var(--s8);
|
||||
display: flex;
|
||||
@@ -883,8 +893,12 @@
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
/* Full width, stated as four insets rather than a width: the box is then
|
||||
defined by the viewport's edges and cannot be a rounding error wider than
|
||||
the thing it is pinned to. */
|
||||
.drawer {
|
||||
width: 100vw;
|
||||
inset: 0;
|
||||
width: auto;
|
||||
border-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -548,7 +548,10 @@
|
||||
ondrop={(e) => onSectionDrop(e, '')}
|
||||
>
|
||||
<TaskList tasks={unsectioned} projectId={project.id} sectionId="" {canEdit} />
|
||||
{#if !unsectioned.length}
|
||||
<!-- Only once there are sections to be "without": on a project with none,
|
||||
the label is a heading for the only list there is, and the empty
|
||||
state below already says the project is empty. -->
|
||||
{#if sections.length && !unsectioned.length}
|
||||
<p class="empty">Uden sektion</p>
|
||||
{/if}
|
||||
</section>
|
||||
@@ -664,17 +667,28 @@
|
||||
max-width: 1400px;
|
||||
}
|
||||
|
||||
/* Wraps, and the title keeps a floor.
|
||||
*
|
||||
* Without both, a phone squeezes the heading to its min-content — which with
|
||||
* `overflow-wrap: anywhere` is *one character*, so "Skovvænget" came out as a
|
||||
* vertical column of letters while the buttons ran off the right edge. Four
|
||||
* controls beside a title is more than 390px has, so they belong on the next
|
||||
* line rather than in a race for the same one.
|
||||
*/
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s4);
|
||||
gap: var(--s3);
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: var(--s5);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: var(--text-2xl);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
/* 8ch rather than 0: the basis is what stops flex shrinking it past the
|
||||
point where the wrapping below is the better answer. */
|
||||
flex: 1 1 8ch;
|
||||
min-width: 8ch;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
@@ -694,8 +708,8 @@
|
||||
|
||||
/* Matches the heading it stands in for, so the swap does not move the page. */
|
||||
.title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
flex: 1 1 8ch;
|
||||
min-width: 8ch;
|
||||
font-size: var(--text-2xl);
|
||||
font-weight: 560;
|
||||
letter-spacing: -0.015em;
|
||||
|
||||
@@ -135,17 +135,21 @@
|
||||
max-width: 1400px;
|
||||
}
|
||||
|
||||
/* Wraps, for the same reason the project header does: a title and a switcher
|
||||
are more than a phone's width, and the switcher belongs underneath rather
|
||||
than squeezing the heading. */
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s4);
|
||||
gap: var(--s3);
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: var(--s5);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: var(--text-2xl);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
flex: 1 1 8ch;
|
||||
min-width: 8ch;
|
||||
}
|
||||
|
||||
.views {
|
||||
|
||||
Reference in New Issue
Block a user