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.
|
# manager and no libc — the attack surface of the running container is the binary.
|
||||||
|
|
||||||
# --- 1. the web interface ----------------------------------------------------
|
# --- 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
|
WORKDIR /src
|
||||||
COPY web/ ./
|
COPY web/ ./
|
||||||
@@ -24,7 +34,12 @@ RUN if [ -f package.json ]; then \
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# --- 2. the binary -----------------------------------------------------------
|
# --- 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
|
WORKDIR /src
|
||||||
|
|
||||||
@@ -37,11 +52,13 @@ COPY . .
|
|||||||
COPY --from=web /src/build ./cmd/verdande/webbuild
|
COPY --from=web /src/build ./cmd/verdande/webbuild
|
||||||
|
|
||||||
ARG VERSION=dev
|
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
|
# 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
|
# 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.
|
# 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 \
|
-tags embedweb \
|
||||||
-trimpath \
|
-trimpath \
|
||||||
-ldflags="-s -w -buildid= -X main.version=${VERSION}" \
|
-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([]);
|
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.
|
* The MCP connector address must not fall through to the app shell.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -18,7 +18,13 @@
|
|||||||
newName = '';
|
newName = '';
|
||||||
adding = false;
|
adding = false;
|
||||||
const project = await app.createProject(name);
|
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() {
|
async function signOut() {
|
||||||
|
|||||||
@@ -575,7 +575,12 @@
|
|||||||
.drawer {
|
.drawer {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0 0 0 auto;
|
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;
|
z-index: 80;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -625,7 +630,12 @@
|
|||||||
|
|
||||||
.scroll {
|
.scroll {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
overflow-y: auto;
|
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;
|
overscroll-behavior: contain;
|
||||||
padding: var(--s4) var(--s4) var(--s8);
|
padding: var(--s4) var(--s4) var(--s8);
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -883,8 +893,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 560px) {
|
@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 {
|
.drawer {
|
||||||
width: 100vw;
|
inset: 0;
|
||||||
|
width: auto;
|
||||||
border-left: 0;
|
border-left: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -548,7 +548,10 @@
|
|||||||
ondrop={(e) => onSectionDrop(e, '')}
|
ondrop={(e) => onSectionDrop(e, '')}
|
||||||
>
|
>
|
||||||
<TaskList tasks={unsectioned} projectId={project.id} sectionId="" {canEdit} />
|
<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>
|
<p class="empty">Uden sektion</p>
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
@@ -664,17 +667,28 @@
|
|||||||
max-width: 1400px;
|
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 {
|
header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--s4);
|
gap: var(--s3);
|
||||||
|
flex-wrap: wrap;
|
||||||
margin-bottom: var(--s5);
|
margin-bottom: var(--s5);
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
font-size: var(--text-2xl);
|
font-size: var(--text-2xl);
|
||||||
flex: 1;
|
/* 8ch rather than 0: the basis is what stops flex shrinking it past the
|
||||||
min-width: 0;
|
point where the wrapping below is the better answer. */
|
||||||
|
flex: 1 1 8ch;
|
||||||
|
min-width: 8ch;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -694,8 +708,8 @@
|
|||||||
|
|
||||||
/* Matches the heading it stands in for, so the swap does not move the page. */
|
/* Matches the heading it stands in for, so the swap does not move the page. */
|
||||||
.title {
|
.title {
|
||||||
flex: 1;
|
flex: 1 1 8ch;
|
||||||
min-width: 0;
|
min-width: 8ch;
|
||||||
font-size: var(--text-2xl);
|
font-size: var(--text-2xl);
|
||||||
font-weight: 560;
|
font-weight: 560;
|
||||||
letter-spacing: -0.015em;
|
letter-spacing: -0.015em;
|
||||||
|
|||||||
@@ -135,17 +135,21 @@
|
|||||||
max-width: 1400px;
|
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 {
|
header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--s4);
|
gap: var(--s3);
|
||||||
|
flex-wrap: wrap;
|
||||||
margin-bottom: var(--s5);
|
margin-bottom: var(--s5);
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
font-size: var(--text-2xl);
|
font-size: var(--text-2xl);
|
||||||
flex: 1;
|
flex: 1 1 8ch;
|
||||||
min-width: 0;
|
min-width: 8ch;
|
||||||
}
|
}
|
||||||
|
|
||||||
.views {
|
.views {
|
||||||
|
|||||||
Reference in New Issue
Block a user